Compare commits

...
Sign in to create a new pull request.

57 commits

Author SHA1 Message Date
pre-commit-ci[bot]
dc42a0d82c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-17 14:55:06 +00:00
Daniel Han
2bbb15994b gemma4 moe 4bit: pure-PyTorch NF4 dequant variant (negative result, kept for docs)
Adds UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_PT_DEQUANT=1 as a 5th forward variant on
top of #5432 + the follow-up active-only path.

Implementation:
- Pre-compute the dequantized per-block absmax (bnb's nested-blockwise
  scheme) once at swap time and cache on each Linear4bit as
  _unsloth_pt_absmax_fp32. Removes bnb from the per-forward path entirely.
- Per-forward: nibble unpack + 16-entry NF4 codebook lookup + per-block
  absmax multiply + reshape to (out, in). All pure tensor ops.
- The dequant+stack helper is wrapped in torch.compile so Inductor can
  fuse with the surrounding stack and grouped_mm.

Numerical parity probe (temp/sim_5344_pt_nf4_probe.py) matches bnb
bit-exactly on a synthetic stub (cos=1.0, max_abs_diff=0). Real-model
swapped forward gives cos 0.996 vs BF16 baseline (vs active-only's 0.985)
because the FP32 intermediate multiply is closer to ideal BF16 weights.

Speed result on gemma-4-26B-A4B-it (B200): 2.17 tok/s vs active-only's
2.21 tok/s. Essentially break-even. Inductor's fusion across the
per-expert iteration in the torch.stack list comprehension is bounded;
the real bottleneck (per-expert dispatch + stack copy) survives the
compile pass.

Resident VRAM +1.33 GB for the cached FP32 absmax buffers.

Kept in the codebase as a negative result + foundation for a future
vectorised-across-experts dequant pass, which would stack packed uint8
and absmax into (E_active, ...) tensors BEFORE the dequant so Inductor
sees a single batched op.

Bit-exact loop-vs-pt_dequant equivalence test:
temp/sim_5344_pt_dequant_unit.py (cos=1.0, max_abs_diff=0 on synthetic
stub with 2 or 4 active experts).
2026-05-17 14:53:18 +00:00
pre-commit-ci[bot]
198c6232d1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-17 14:10:01 +00:00
Daniel Han
6c76ae9f9d gemma4 moe 4bit: active-only + cache + static_bf16 forward variants (#5344 follow-up)
Follow-up to #5432. Closes ~half the speed gap between the 4-bit loop forward
and the BF16 grouped_mm path by skipping dequantization of inactive experts.

Three new forward variants, all gated by env vars on top of the existing
UNSLOTH_GEMMA4_MOE_4BIT_GROUPED=1:

1. UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_ACTIVE_ONLY=1
   Dequant only the experts touched by the current batch's top-k routing
   instead of all num_experts. Uses torch.unique(top_k_index) for the active
   set and remaps top_k_index from [0, num_experts) to [0, E_active). Bench
   on gemma-4-26B-A4B-it shows 2.71-3.01 tok/s vs the loop forward's 1.59
   tok/s (~1.7-2.0x speedup), same VRAM (14.27 GB resident, +0.4 GB peak),
   logits cos_sim 0.998 vs loop. Bit-exact on the synthetic stub.

2. UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_CACHE=1 (requires _ACTIVE_ONLY=1)
   Per-module LRU of dequantized BF16 experts. Capped at UNSLOTH_GEMMA4_MOE_4BIT_CACHE_SIZE
   (default 8). Empirically break-even with active-only on the 26B-A4B bench
   because the torch.stack-into-compact-tensor cost remains; the cache only
   skips the dequant math.

3. UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_STATIC_BF16=1
   Speed-ceiling experiment: dequant every expert ONCE on first forward,
   keep fused BF16 tensors live for the rest of the module's lifetime.
   Reaches 5.98 tok/s on 26B-A4B (within 6% of BF16 baseline 6.34) at the
   cost of peak forward VRAM 57 GB (the 4-bit storage is still resident, so
   net is BF16 + 4-bit cohabit). Proves per-forward dequant is the
   bottleneck; the remaining ~2x gap cannot be closed without persistent
   fused weights or a custom 4-bit grouped GEMM kernel.

UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_COMPILE=1 also included as a negative
result: torch.compile on the dequant helper regresses to 1.03 tok/s
because bnb.functional.dequantize_4bit is a custom CUDA op (graph break)
and Inductor can't fuse around it.

Forward selection ladder in swap_gemma4_experts_to_per_expert_linear4bit:
STATIC_BF16 > ACTIVE_ONLY+CACHE > ACTIVE_ONLY > GROUPED > loop. The env
vars are mutually compatible; later flags layer on earlier ones.

Unit equivalence tests in temp/sim_5344_{active_only,cached,static_bf16}_unit.py
confirm bit-exactness (cos=1.0, max_abs_diff=0) on a synthetic
Gemma4TextExperts stub. Real-model bench in
temp/sim_5344_grouped_4bit_bench.py with selectable BENCH_MODES.

Design notes + full Pareto frontier in outputs/moe_4bit_speed_summary.md.
2026-05-17 14:09:04 +00:00
pre-commit-ci[bot]
f16d0df146 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-16 15:47:56 +00:00
Daniel Han
b05315b19a Sync .github/workflows with upstream author branch 2026-05-16 15:47:30 +00:00
Daniel Han
6746f4d100 Trim redundant comments and docstrings on the Gemma-4 MoE swap path
Shorten WHAT-style narrative on private helpers (_per_expert_forward,
_quantize_one_expert_to_linear4bit) to one-line WHY statements; collapse
the three-line per-module peak-VRAM note to a single line; drop the
three-line opt-in description at the swap call site since the closure
name already conveys the intent.
2026-05-16 15:46:16 +00:00
Daniel Han
09ed2b963d Make Gemma-4 MoE swap honor full quantization_config and cover vLLM path
- Normalize string compute_dtype values from dict-style quantization_config
  (e.g. {"bnb_4bit_compute_dtype": "bfloat16"}) into torch.dtype before
  forwarding to bitsandbytes. A raw string would propagate to
  bnb.nn.Linear4bit and crash the first forward with
  "Invalid device string: 'bfloat16'".

- Forward bnb_4bit_quant_type from the user's BitsAndBytesConfig or dict
  config into swap_gemma4_experts_to_per_expert_linear4bit so swapped
  experts match the quantization type used by the rest of the model
  (previously always nf4 even when the caller requested fp4).

- Wrap the swap and its warning into a local closure and invoke it from
  both the regular auto_model.from_pretrained branch and the
  fast_inference=True / convert_vllm_to_huggingface branch. The closure
  is idempotent on non-Gemma-4 models, so the vLLM call is free when the
  loaded model has no Gemma4TextExperts modules.

- Escalate partial-state swap failures: if the helper raises after one
  or more Gemma4TextExperts modules were already committed to 4-bit,
  the wrapper re-raises a RuntimeError instructing the caller to reload
  the model. Previously a warning implied a clean BF16 fallback, which
  is false when partial conversion has already occurred.

The closure is multi-line (long block) because it needs to capture the
already-resolved quantization parameters and be reusable across both
load paths; the alternative is duplicating the entire block.
2026-05-16 15:46:16 +00:00
Daniel Han
41f8792e7c Honor quantization_config.load_in_4bit for Gemma-4 MoE swap
The Gemma-4 MoE per-expert Linear4bit swap previously gated only on the
positional load_in_4bit argument. loader.py forwards load_in_4bit=False
to FastBaseModel.from_pretrained whenever the caller supplies a
quantization_config (BitsAndBytesConfig), so callers that opt in via
UNSLOTH_GEMMA4_MOE_4BIT=1 plus BitsAndBytesConfig(load_in_4bit=True)
silently bypassed the swap. The adjacent guardrail already normalises
load_in_4bit from quantization_config; the swap gate now does the same
and sources bnb_4bit_compute_dtype from quantization_config when no
local bnb_config is built.

The except branch around the swap also previously stated "Falling back
to BF16 experts", which misrepresents the model state when the helper
fails partway through (already-swapped Gemma4TextExperts modules stay
in 4-bit; only the remainder remain BF16). The warning now counts the
modules marked _unsloth_gemma4_moe_4bit_swapped and reports the partial
state, advising a reload to recover a uniform state.

The comment above the fused-Parameter dels in gemma4_moe_4bit.py
overstated the swap's memory bound; rephrased to describe the actual
per-module peak (fused BF16 plus accumulated per-expert nf4).
2026-05-16 15:46:16 +00:00
Daniel Han
5a09093305 Merge quantization-bypass guardrail base into feature branch 2026-05-16 15:46:16 +00:00
Daniel Han
737643b173 Scrub .github/workflows for staging push (matches staging base) 2026-05-16 14:20:53 +00:00
pre-commit-ci[bot]
610e15819a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-16 14:13:55 +00:00
Daniel Han
531706afa2 Harden quantization-bypass guardrail and apply to all bnb load paths
- Fix NameError on full_finetuning in FastLlamaModel.from_pretrained
  causal-LM branch; pull from kwargs instead of an undefined local.
- Apply the guardrail to the sequence-classification branch so
  num_labels users get the silent-bypass warning too. Block is appended
  purely additively; the prior `# Attach dispatch hooks` comment and
  `_attach_bnb_multidevice_hooks` import (added earlier for multi-GPU
  bnb dispatch hardening) are preserved untouched so that protection is
  not regressed.
- Count torch.int8 alongside torch.uint8 as quantized payload; bnb
  Linear8bitLt / Int8Params stores 8-bit weights as int8 post-cuda, so
  the previous accumulator zeroed and false-fired the partial warning.
- Tighten partial-bypass condition to require quantized_bytes > 0, so
  the warning only fires when quantization actually produced payload.
- Drop the overbroad "mlp.gate" skip pattern; it suppressed fused/custom
  mlp.gate_proj / mlp.gate_up_proj bulk weights that are exactly the
  partial-bypass shape this guard must report. Standard Linear4bit
  gate_proj weights are uint8 and counted as quantized earlier, so no
  new false positive on healthy 4-bit loads.
- Use the real bnb class name (Linear8bitLt) in the 8-bit total-bypass
  message instead of synthesising "Linear8bit".
- Accept an optional quantization_config so callers passing
  BitsAndBytesConfig directly (loader.py sets load_in_4bit_kwargs=False
  in that path) still get the bypass check.
- Drop a duplicate transformers_version import in vision.py.
2026-05-16 14:10:15 +00:00
Daniel Han
f533c2257d
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 21:14:28 -07:00
Daniel Han
6faebabff9
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 21:13:24 -07:00
Daniel Han
a68747aaff
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 20:54:06 -07:00
Daniel Han
cefcf8e2d9
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 20:52:57 -07:00
Daniel Han
56f9d94254
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 20:50:27 -07:00
Daniel Han
5e90beaf69
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 20:49:23 -07:00
Daniel Han
cab5a88e65
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 19:42:24 -07:00
Daniel Han
642324a41d
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 19:41:20 -07:00
Daniel Han
3948cc0522
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 15:55:12 -07:00
Daniel Han
af63ff2eed
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 15:54:06 -07:00
Daniel Han
457930c7db
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 15:11:08 -07:00
Daniel Han
f4fbe4a49f
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 15:10:03 -07:00
Daniel Han
96b7497a7e
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 14:46:09 -07:00
Daniel Han
62a36d92b8
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 14:45:03 -07:00
Daniel Han
1a89a0b616
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 14:19:22 -07:00
Daniel Han
f6014c32ca
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 14:18:17 -07:00
Daniel Han
48291ffab4
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 13:15:53 -07:00
Daniel Han
466d41c1b9
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 13:14:48 -07:00
Daniel Han
3fa2ccb4f0
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 11:48:11 -07:00
Daniel Han
514a17d95e
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 11:47:06 -07:00
Daniel Han
91d3e6925d
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 11:03:34 -07:00
Daniel Han
71f5d7e547
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 11:02:29 -07:00
Daniel Han
5753728eeb
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 10:39:01 -07:00
Daniel Han
a187a18581
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 10:37:51 -07:00
Daniel Han
763a6936cc
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 09:38:50 -07:00
Daniel Han
11af7dbb31
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 09:37:40 -07:00
Daniel Han
5ae1456e19
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 07:48:04 -07:00
Daniel Han
d257df9fbe
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 07:46:54 -07:00
Daniel Han
678e488785
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 06:51:32 -07:00
Daniel Han
ee95f9a9a7
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 06:50:53 -07:00
Daniel Han
3e86feacc0
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 05:36:54 -07:00
Daniel Han
61c832fc05
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 05:36:16 -07:00
Daniel Han
3394d1a81d
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 03:54:59 -07:00
Daniel Han
c78d0543fc
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 03:54:35 -07:00
Daniel Han
f2cb42f747
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 03:52:27 -07:00
Daniel Han
4ac5fc0405
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 03:52:08 -07:00
Daniel Han
23ac8fc227
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 01:26:51 -07:00
Daniel Han
fe7815f8f2
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 01:26:20 -07:00
Daniel Han
8bad7ac1b7
Merge branch 'main' into fix-issue-5344-quantization-guardrail 2026-05-15 00:12:10 -07:00
Daniel Han
cfdda21620
Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap 2026-05-15 00:12:07 -07:00
pre-commit-ci[bot]
782fe01381 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-15 03:50:06 +00:00
Daniel Han
ddf54efa5f gemma-4 moe: per-expert Linear4bit swap so 26B-A4B fits at 4-bit (#5344)
unsloth/gemma-4-26B-A4B-it loads at ~46 GB even with load_in_4bit=True
because Gemma4TextExperts stores experts as fused 3D nn.Parameter tensors
(gate_up_proj of shape (128, 1408, 2816), down_proj of (128, 2816, 704))
so torch._grouped_mm can dispatch a single grouped matmul per layer.
bitsandbytes' replace_with_bnb_linear only swaps nn.Linear instances, so
the fused expert weights stay BF16 and dominate the VRAM footprint.

This adds an opt-in helper that walks the loaded model, finds every
Gemma4TextExperts module, slices each fused (E, O, I) Parameter into E
individual bnb.nn.Linear4bit modules (per-expert), and patches forward
to dispatch per-expert instead of via torch._grouped_mm.

Trade-off:

- VRAM win: 46 GB -> 14.27 GB resident on unsloth/gemma-4-26B-A4B-it
  (B200, transformers 5.5.0, single GPU). Linear4bit count 206 -> 7886.
  Forward-pass cosine similarity vs BF16 reference is 0.994 on a fixed
  prompt, i.e. standard QLoRA fidelity.

- Throughput loss: per-expert dispatch loses the grouped_mm speedup.
  Acceptable for "model fits at 4-bit on a single GPU"; QLoRA training
  still needs the matching per-expert LoRA path which is not in this PR.

Gated on UNSLOTH_GEMMA4_MOE_4BIT=1, default off until the per-expert
LoRA path lands (the swap renames gate_up_proj -> gate_up_proj_4bit
which would break unsloth_zoo's grouped_mm LoRA extractor as-is).

The renamed attributes also make the helper idempotent: re-entering it
sees `_unsloth_gemma4_moe_4bit_swapped` and no-ops, so multiple calls
across nested loaders are safe.

No regression on non-MoE checkpoints: the helper only touches modules
that are isinstance(Gemma4TextExperts) with the expected 3D shape.

Tests cover env-var gating, no-op behaviour on non-Gemma4 models, the
transformers-without-gemma4 ImportError path, and idempotence on a stub
Gemma4TextExperts module.

Refs #5344
2026-05-15 03:49:17 +00:00
pre-commit-ci[bot]
5be466ed6e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-15 03:47:13 +00:00
Daniel Han
e2a82b5a10 guardrail: detect silent 4-bit / 8-bit quantization bypass (#5344)
Some users report that load_in_4bit=True is silently ignored on certain
checkpoints and the model is loaded in full precision. This adds a post-load
guardrail in FastBaseModel.from_pretrained and FastLlamaModel.from_pretrained
that detects two failure modes and emits a clear warning instead of letting
the user discover the issue via a confusing VRAM blow-up.

1. Total bypass: load_in_4bit=True requested, zero bitsandbytes Linear4bit /
   Linear8bitLt modules in the loaded model. Usually a transformers / bnb
   version mismatch or a backend-incompatible device_map.

2. Partial bypass: bnb quantized nn.Linear but a large fraction of weight
   bytes live in non-nn.Linear Parameters that are not in the bnb skip list.
   This catches the Gemma-4 MoE class where Gemma4TextExperts stores experts
   as fused 3D nn.Parameter tensors for torch._grouped_mm; bnb's
   replace_with_bnb_linear only swaps nn.Linear instances, so the fused
   expert weights stay in BF16 and dominate the VRAM footprint. The
   warning names the worst offenders so the user can correlate.

warnings.warn (not raise) so CPU / MLX / AMD-without-bnb backends that
legitimately have no Linear4bit modules are not broken.

Tests in tests/test_issue_5344_guardrail.py cover both branches plus the
full-finetuning, no-quant, and skip-list cases.

Refs #5344
2026-05-15 03:45:41 +00:00
5 changed files with 1215 additions and 3 deletions

View file

@ -0,0 +1,147 @@
"""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.")

View file

@ -0,0 +1,177 @@
"""Unit tests for the unslothai/unsloth#5344 silent-quantization-bypass guardrail.
Covers two failure modes the helper detects:
1. total bypass: load_in_4bit was requested but no bnb modules exist.
2. partial bypass: bnb quantized nn.Linear but a large fraction of weight
bytes live in non-nn.Linear Parameters (e.g. Gemma-4 MoE fused experts).
"""
import warnings
import torch
import torch.nn as nn
# unsloth must be imported before transformers per its loading order, but
# these tests do not exercise the real loader. Import the helper directly.
from unsloth.models.vision import _warn_if_quantization_silently_dropped
class _PretendLinear4bit(nn.Module):
"""type(m).__name__ == 'Linear4bit' so the guardrail counts it as quantized."""
def __init__(self):
super().__init__()
self.weight = nn.Parameter(
torch.zeros(1, dtype = torch.uint8),
requires_grad = False,
)
_PretendLinear4bit.__name__ = "Linear4bit"
def _unquantized_model():
return nn.Sequential(nn.Linear(4, 4), nn.Linear(4, 4))
def _quantized_model():
return nn.Sequential(nn.Linear(4, 4), _PretendLinear4bit())
def test_fires_when_4bit_requested_but_no_bnb_modules():
model = _unquantized_model()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = True,
load_in_8bit = False,
full_finetuning = False,
)
msgs = [str(w.message) for w in caught]
assert any("load_in_4bit=True was requested" in m for m in msgs), msgs
assert any("issues/5344" in m for m in msgs), msgs
def test_silent_when_4bit_succeeded():
model = _quantized_model()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = True,
load_in_8bit = False,
full_finetuning = False,
)
msgs = [str(w.message) for w in caught]
assert not any("load_in_4bit" in m for m in msgs), msgs
def test_silent_for_full_finetuning():
model = _unquantized_model()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = False,
load_in_8bit = False,
full_finetuning = True,
)
msgs = [str(w.message) for w in caught]
assert not any("load_in_4bit" in m or "load_in_8bit" in m for m in msgs), msgs
def test_silent_when_no_quantization_requested():
model = _unquantized_model()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = False,
load_in_8bit = False,
full_finetuning = False,
)
msgs = [str(w.message) for w in caught]
assert not any("load_in_4bit" in m or "load_in_8bit" in m for m in msgs), msgs
def test_fires_for_8bit_silent_bypass():
model = _unquantized_model()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = False,
load_in_8bit = True,
full_finetuning = False,
)
msgs = [str(w.message) for w in caught]
assert any("load_in_8bit=True was requested" in m for m in msgs), msgs
class _MoEFusedExpertWrapper(nn.Module):
"""Mimics Gemma4TextExperts: fused 3D weights stored as nn.Parameter, not
as separate nn.Linear instances. bnb's replace_with_bnb_linear skips this."""
def __init__(self, num_experts = 128, intermediate = 1408, hidden = 2816):
super().__init__()
self.gate_up_proj = nn.Parameter(
torch.zeros((num_experts, intermediate, hidden), dtype = torch.bfloat16),
requires_grad = False,
)
def _partial_quant_model():
return nn.Sequential(_PretendLinear4bit(), _MoEFusedExpertWrapper())
def test_fires_on_partial_quant_moe_experts():
model = _partial_quant_model()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = True,
load_in_8bit = False,
full_finetuning = False,
)
msgs = [str(w.message) for w in caught]
assert any("partially applied" in m for m in msgs), msgs
assert any("gate_up_proj" in m for m in msgs), msgs
class _NormParam(nn.Module):
"""An RMSNorm-like module: large BF16 weight whose name is in the skip list."""
def __init__(self, dim = 8 * 1024 * 1024 + 10):
super().__init__()
self.norm_weight = nn.Parameter(
torch.zeros(dim, dtype = torch.bfloat16),
requires_grad = False,
)
def test_silent_when_only_skip_list_tensors_unquantized():
model = nn.Sequential(_PretendLinear4bit(), _NormParam())
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = True,
load_in_8bit = False,
full_finetuning = False,
)
msgs = [str(w.message) for w in caught]
assert not any("partially applied" in m for m in msgs), msgs
if __name__ == "__main__":
test_fires_when_4bit_requested_but_no_bnb_modules()
test_silent_when_4bit_succeeded()
test_silent_for_full_finetuning()
test_silent_when_no_quantization_requested()
test_fires_for_8bit_silent_bypass()
test_fires_on_partial_quant_moe_experts()
test_silent_when_only_skip_list_tensors_unquantized()
print("All 7 guardrail tests passed.")

View file

@ -0,0 +1,648 @@
# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Per-expert bitsandbytes Linear4bit swap for Gemma-4 MoE experts.
Refs: https://github.com/unslothai/unsloth/issues/5344
Gemma4TextExperts stores all experts as two fused 3D Parameters
(gate_up_proj, down_proj) shaped (num_experts, out_dim, in_dim) so that
torch._grouped_mm can dispatch a single grouped matmul per layer. The
fused storage is great for forward throughput but breaks bnb 4-bit
quantization: bnb.nn.Linear4bit only swaps nn.Linear instances, so the
fused 3D Parameters stay in BF16, defeating QLoRA VRAM savings.
This module swaps each Gemma4TextExperts module's fused weights for two
nn.ModuleList[Linear4bit] of length num_experts, and overrides forward to
dispatch per-expert. The trade-off is the loss of torch._grouped_mm
throughput in exchange for a ~4x reduction in expert weight VRAM
(measured on unsloth/gemma-4-26B-A4B-it: 46 GB -> 14.27 GB resident).
Gated on UNSLOTH_GEMMA4_MOE_4BIT (default off) and on load_in_4bit=True.
Default off until the matching per-expert LoRA path lands; opt in via
the env var if you want the VRAM win without QLoRA training.
"""
from __future__ import annotations
import os
from types import MethodType
import torch
import torch.nn as nn
__all__ = [
"is_gemma4_moe_4bit_enabled",
"is_gemma4_moe_4bit_grouped_enabled",
"is_gemma4_moe_4bit_grouped_active_only_enabled",
"swap_gemma4_experts_to_per_expert_linear4bit",
]
def is_gemma4_moe_4bit_enabled() -> bool:
"""Opt-in via UNSLOTH_GEMMA4_MOE_4BIT=1."""
return os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT", "0") == "1"
def is_gemma4_moe_4bit_grouped_enabled() -> bool:
"""Opt-in via UNSLOTH_GEMMA4_MOE_4BIT_GROUPED=1. Requires the base swap to
also be enabled. Uses dequant-then-torch._grouped_mm per forward instead of
a per-expert Linear4bit loop; trades transient BF16 staging buffer for
grouped-GEMM throughput. See unslothai/unsloth#5344 follow-up."""
return os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_GROUPED", "0") == "1"
def is_gemma4_moe_4bit_grouped_active_only_enabled() -> bool:
"""Opt-in via UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_ACTIVE_ONLY=1. Requires both
base swap + grouped. Dequantizes only the experts touched by the current
batch's top-k routing (~k*B active per layer) instead of all num_experts.
For Gemma-4 MoE (128 experts, top-k=4) this cuts dequant work an order of
magnitude in autoregressive decode."""
return os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_ACTIVE_ONLY", "0") == "1"
def is_gemma4_moe_4bit_grouped_cached_enabled() -> bool:
"""Opt-in via UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_CACHE=1. Layered on active-only:
cache the BF16 dequantized expert weights in a per-module LRU. Decode
revisits to the same expert skip the dequant entirely. Cache cap via
UNSLOTH_GEMMA4_MOE_4BIT_CACHE_SIZE (default 8)."""
return os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_CACHE", "0") == "1"
def is_gemma4_moe_4bit_grouped_pt_dequant_enabled() -> bool:
"""Opt-in via UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_PT_DEQUANT=1. Layered on
grouped+active_only. Replaces bnb.functional.dequantize_4bit with a
pure-tensor NF4 dequant so torch.compile can fuse the unpack + codebook
lookup + stack + grouped_mm into one Inductor graph. Per-block absmax is
pre-dequantized at swap time and cached on each Linear4bit, so the
per-forward path is bnb-free."""
return os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_PT_DEQUANT", "0") == "1"
def is_gemma4_moe_4bit_grouped_static_bf16_enabled() -> bool:
"""Opt-in via UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_STATIC_BF16=1. Dequant every
expert ONCE on the first forward and keep the fused (E, 2I, H) / (E, H, I)
BF16 tensors live for the lifetime of the module. Subsequent forwards skip
dequant entirely. Wins back grouped_mm throughput at the cost of holding
a permanent BF16 mirror -- i.e. peak VRAM rises back toward the BF16
baseline. Useful as a speed-ceiling experiment and for inference workloads
that have spare VRAM."""
return os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_STATIC_BF16", "0") == "1"
def _per_expert_forward(
self,
hidden_states: torch.Tensor,
top_k_index: torch.Tensor,
top_k_weights: torch.Tensor,
) -> torch.Tensor:
"""Replacement Gemma4TextExperts.forward dispatching through swapped
nn.ModuleList[Linear4bit] instead of fused 3D Parameters."""
final_hidden_states = torch.zeros_like(hidden_states)
with torch.no_grad():
expert_mask = torch.nn.functional.one_hot(
top_k_index,
num_classes = self.num_experts,
)
expert_mask = expert_mask.permute(2, 1, 0)
expert_hit = torch.greater(expert_mask.sum(dim = (-1, -2)), 0).nonzero()
for expert_idx in expert_hit:
expert_idx = expert_idx[0]
if expert_idx == self.num_experts:
continue
top_k_pos, token_idx = torch.where(expert_mask[expert_idx])
current_state = hidden_states[token_idx]
gate_up = self.gate_up_proj_4bit[expert_idx](current_state)
gate, up = gate_up.chunk(2, dim = -1)
current_hidden_states = self.act_fn(gate) * up
current_hidden_states = self.down_proj_4bit[expert_idx](current_hidden_states)
current_hidden_states = (
current_hidden_states * top_k_weights[token_idx, top_k_pos, None]
)
final_hidden_states.index_add_(
0,
token_idx,
current_hidden_states.to(final_hidden_states.dtype),
)
return final_hidden_states
# NF4 codebook (16 entries, bnb convention). Used by the pure-PyTorch dequant
# path which torch.compile can fuse with the surrounding stack + grouped_mm.
_NF4_CODES = torch.tensor(
[
-1.0,
-0.6961928009986877,
-0.5250730514526367,
-0.39491748809814453,
-0.28444138169288635,
-0.18477343022823334,
-0.09105003625154495,
0.0,
0.07958029955625534,
0.16093020141124725,
0.24611230194568634,
0.33791524171829224,
0.44070982933044434,
0.5626170039176941,
0.7229568362236023,
1.0,
],
dtype = torch.float32,
)
_COMPILED_PT_DEQUANT_STACK = None
_COMPILED_DEQUANT_STACK = None
def _ensure_pt_dequant_state(layer):
"""Cache the dequantized absmax + codebook on the layer so the per-forward
path needs no bnb calls. Idempotent. Called at swap time."""
if getattr(layer, "_unsloth_pt_dequant_ready", False):
return
from bitsandbytes.functional import dequantize_blockwise
qs = layer.weight.quant_state
if qs.nested:
absmax_fp32 = dequantize_blockwise(qs.absmax, qs.state2)
absmax_fp32 = (absmax_fp32 + qs.offset).to(torch.float32)
else:
absmax_fp32 = qs.absmax.to(torch.float32)
layer._unsloth_pt_absmax_fp32 = absmax_fp32.contiguous()
layer._unsloth_pt_blocksize = qs.blocksize
layer._unsloth_pt_shape = tuple(qs.shape)
layer._unsloth_pt_dtype = qs.dtype
layer._unsloth_pt_dequant_ready = True
def _pt_dequant_one(packed_uint8, absmax_fp32, blocksize, shape, dtype, codes):
"""Pure-PyTorch NF4 dequant of one expert weight. Bit-exact vs bnb.
Pure tensor ops -> torch.compile-friendly."""
packed = packed_uint8.reshape(-1)
high = (packed >> 4) & 0xF
low = packed & 0xF
indices = torch.stack([high, low], dim = -1).reshape(-1).to(torch.long)
values = codes[indices] # fp32
n_elements = values.numel()
n_blocks = (n_elements + blocksize - 1) // blocksize
values = values.view(n_blocks, blocksize) * absmax_fp32.view(-1, 1)
target = shape[0] * shape[1]
return values.reshape(-1)[:target].view(shape).to(dtype)
def _pt_dequant_stack_subset(layers, indices_cpu, codes):
"""Pure-PyTorch dequant of a subset of experts and stack into (E_active, out, in)."""
return torch.stack(
[
_pt_dequant_one(
layers[i].weight.data,
layers[i]._unsloth_pt_absmax_fp32,
layers[i]._unsloth_pt_blocksize,
layers[i]._unsloth_pt_shape,
layers[i]._unsloth_pt_dtype,
codes,
)
for i in indices_cpu
],
dim = 0,
)
def _get_compiled_pt_dequant_stack():
"""Lazy-compile the pure-PT dequant+stack helper. Re-used across forwards."""
global _COMPILED_PT_DEQUANT_STACK
if _COMPILED_PT_DEQUANT_STACK is None:
_COMPILED_PT_DEQUANT_STACK = torch.compile(
_pt_dequant_stack_subset,
dynamic = True,
fullgraph = False,
)
return _COMPILED_PT_DEQUANT_STACK
def _dequant_stack(layers):
"""Dequantize each Linear4bit in a ModuleList and stack into (E, out, in)."""
from bitsandbytes.functional import dequantize_4bit
return torch.stack(
[dequantize_4bit(L.weight.data, L.weight.quant_state) for L in layers],
dim = 0,
)
def _dequant_stack_subset(layers, indices_cpu):
"""Dequantize only experts whose CPU-int indices are in indices_cpu."""
from bitsandbytes.functional import dequantize_4bit
return torch.stack(
[
dequantize_4bit(layers[i].weight.data, layers[i].weight.quant_state)
for i in indices_cpu
],
dim = 0,
)
def _get_compiled_dequant_stack():
"""Lazily compile the dequant+stack helper. Done once and cached so
successive forwards reuse the compiled graph (otherwise the per-call
compile cost dwarfs the runtime saving)."""
global _COMPILED_DEQUANT_STACK
if _COMPILED_DEQUANT_STACK is None:
_COMPILED_DEQUANT_STACK = torch.compile(
_dequant_stack,
dynamic = False,
fullgraph = False,
)
return _COMPILED_DEQUANT_STACK
def _grouped_mm_forward_4bit(
self,
hidden_states: torch.Tensor,
top_k_index: torch.Tensor,
top_k_weights: torch.Tensor,
) -> torch.Tensor:
"""Dequantize per-expert Linear4bit weights into a fused 3D BF16 tensor and
run unsloth_zoo.forward_native_grouped_mm. Buffer is freed at end of each
forward so resident VRAM stays at 4-bit. Transient peak per layer is
(E * 2I * H + E * H * I) * 2 bytes BF16. Opt-in compile of the dequant
helper via UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_COMPILE=1; bnb's CUDA dequant
is a graph break so the win is bounded by kernel-launch overhead saved
via CUDA-graph capture, not by Inductor fusion."""
from unsloth_zoo.temporary_patches.moe_utils import (
forward_native_grouped_mm,
)
if os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_GROUPED_COMPILE", "0") == "1":
dequant_stack = _get_compiled_dequant_stack()
else:
dequant_stack = _dequant_stack
gate_up = dequant_stack(self.gate_up_proj_4bit)
down = dequant_stack(self.down_proj_4bit)
self.gate_up_proj = nn.Parameter(gate_up, requires_grad = False)
self.down_proj = nn.Parameter(down, requires_grad = False)
try:
return forward_native_grouped_mm(
self,
hidden_states,
top_k_index,
top_k_weights,
)
finally:
del self.gate_up_proj
del self.down_proj
def _grouped_mm_forward_4bit_active_only(
self,
hidden_states: torch.Tensor,
top_k_index: torch.Tensor,
top_k_weights: torch.Tensor,
) -> torch.Tensor:
"""Like _grouped_mm_forward_4bit but only dequants the experts that this
batch's top-k routing actually touches. Remaps top_k_index from the full
expert space [0, num_experts) to the compact active range [0, E_active)
so torch._grouped_mm sees only the populated groups.
For decode (S*K << num_experts) the saving is large; for prefill where
most experts get hit the active set approaches num_experts and the
overhead of the unique+remap is dominated by the dequant savings."""
from unsloth_zoo.temporary_patches.moe_utils import (
forward_native_grouped_mm,
)
flat = top_k_index.reshape(-1)
active_experts, inverse = torch.unique(flat, return_inverse = True)
active_cpu = active_experts.tolist()
n_active = len(active_cpu)
gate_up = _dequant_stack_subset(self.gate_up_proj_4bit, active_cpu)
down = _dequant_stack_subset(self.down_proj_4bit, active_cpu)
compact_top_k = inverse.view_as(top_k_index)
saved_n_experts = self.num_experts
self.num_experts = n_active
self.gate_up_proj = nn.Parameter(gate_up, requires_grad = False)
self.down_proj = nn.Parameter(down, requires_grad = False)
try:
return forward_native_grouped_mm(
self,
hidden_states,
compact_top_k,
top_k_weights,
)
finally:
del self.gate_up_proj
del self.down_proj
self.num_experts = saved_n_experts
def _cached_dequant(module, attr_name, expert_idx, layer):
"""LRU dequant cache for one expert's weight. Hit returns the cached BF16
tensor; miss dequants, stores, and evicts oldest. Cache cap is per-attribute
per Gemma4TextExperts module so each layer maintains its own working set."""
from bitsandbytes.functional import dequantize_4bit
cache_attr = f"_unsloth_dequant_cache_{attr_name}"
cache = getattr(module, cache_attr, None)
if cache is None:
from collections import OrderedDict
cache = OrderedDict()
setattr(module, cache_attr, cache)
cached = cache.get(expert_idx, None)
if cached is not None:
cache.move_to_end(expert_idx)
return cached
w = dequantize_4bit(layer.weight.data, layer.weight.quant_state)
cache[expert_idx] = w
cap = int(os.environ.get("UNSLOTH_GEMMA4_MOE_4BIT_CACHE_SIZE", "8"))
while len(cache) > cap:
cache.popitem(last = False)
return w
def _grouped_mm_forward_4bit_pt_compiled(
self,
hidden_states: torch.Tensor,
top_k_index: torch.Tensor,
top_k_weights: torch.Tensor,
) -> torch.Tensor:
"""Active-only grouped forward using pure-PyTorch NF4 dequant + torch.compile.
Pre-cached per-expert absmax means the per-forward path is bnb-free, so
Inductor can fuse unpack + codebook lookup + stack into one Triton
kernel."""
from unsloth_zoo.temporary_patches.moe_utils import (
forward_native_grouped_mm,
)
flat = top_k_index.reshape(-1)
active_experts, inverse = torch.unique(flat, return_inverse = True)
active_cpu = active_experts.tolist()
n_active = len(active_cpu)
codes = _NF4_CODES.to(hidden_states.device)
dequant_fn = _get_compiled_pt_dequant_stack()
gate_up = dequant_fn(self.gate_up_proj_4bit, active_cpu, codes)
down = dequant_fn(self.down_proj_4bit, active_cpu, codes)
compact_top_k = inverse.view_as(top_k_index)
saved_n_experts = self.num_experts
self.num_experts = n_active
self.gate_up_proj = nn.Parameter(gate_up, requires_grad = False)
self.down_proj = nn.Parameter(down, requires_grad = False)
try:
return forward_native_grouped_mm(
self,
hidden_states,
compact_top_k,
top_k_weights,
)
finally:
del self.gate_up_proj
del self.down_proj
self.num_experts = saved_n_experts
def _grouped_mm_forward_4bit_static_bf16(
self,
hidden_states: torch.Tensor,
top_k_index: torch.Tensor,
top_k_weights: torch.Tensor,
) -> torch.Tensor:
"""First call: dequant every expert to a permanent (E, 2I, H) / (E, H, I)
BF16 fused tensor stored on the module. Subsequent calls reuse them and
skip dequant entirely. Speed-ceiling experiment: trades the 4-bit VRAM
win for grouped_mm throughput."""
from unsloth_zoo.temporary_patches.moe_utils import (
forward_native_grouped_mm,
)
if not hasattr(self, "_unsloth_static_bf16_gate_up"):
self._unsloth_static_bf16_gate_up = _dequant_stack(self.gate_up_proj_4bit)
self._unsloth_static_bf16_down = _dequant_stack(self.down_proj_4bit)
self.gate_up_proj = self._unsloth_static_bf16_gate_up
self.down_proj = self._unsloth_static_bf16_down
try:
return forward_native_grouped_mm(
self,
hidden_states,
top_k_index,
top_k_weights,
)
finally:
del self.gate_up_proj
del self.down_proj
def _grouped_mm_forward_4bit_active_only_cached(
self,
hidden_states: torch.Tensor,
top_k_index: torch.Tensor,
top_k_weights: torch.Tensor,
) -> torch.Tensor:
"""Active-only grouped forward with a per-module LRU cache of dequantized
experts. Decode patterns where the same experts get hit across consecutive
tokens skip the dequant on the second visit. Cache cap via
UNSLOTH_GEMMA4_MOE_4BIT_CACHE_SIZE (default 8 per module)."""
from unsloth_zoo.temporary_patches.moe_utils import (
forward_native_grouped_mm,
)
flat = top_k_index.reshape(-1)
active_experts, inverse = torch.unique(flat, return_inverse = True)
active_cpu = active_experts.tolist()
n_active = len(active_cpu)
gate_up = torch.stack(
[
_cached_dequant(self, "gate_up", i, self.gate_up_proj_4bit[i])
for i in active_cpu
],
dim = 0,
)
down = torch.stack(
[_cached_dequant(self, "down", i, self.down_proj_4bit[i]) for i in active_cpu],
dim = 0,
)
compact_top_k = inverse.view_as(top_k_index)
saved_n_experts = self.num_experts
self.num_experts = n_active
self.gate_up_proj = nn.Parameter(gate_up, requires_grad = False)
self.down_proj = nn.Parameter(down, requires_grad = False)
try:
return forward_native_grouped_mm(
self,
hidden_states,
compact_top_k,
top_k_weights,
)
finally:
del self.gate_up_proj
del self.down_proj
self.num_experts = saved_n_experts
def _quantize_one_expert_to_linear4bit(
weight_2d: torch.Tensor,
compute_dtype: torch.dtype,
quant_type: str = "nf4",
):
"""Build a bnb.nn.Linear4bit from a single (out, in) weight slice.
Params4bit triggers on-the-fly quantization on .to(device)."""
import bitsandbytes as bnb
out_features, in_features = weight_2d.shape
layer = bnb.nn.Linear4bit(
in_features,
out_features,
bias = False,
compute_dtype = compute_dtype,
quant_type = quant_type,
quant_storage = torch.uint8,
)
layer.weight = bnb.nn.Params4bit(
data = weight_2d.detach().clone().contiguous(),
requires_grad = False,
quant_type = quant_type,
)
return layer
def swap_gemma4_experts_to_per_expert_linear4bit(
model: nn.Module,
compute_dtype: torch.dtype = torch.bfloat16,
quant_type: str = "nf4",
verbose: bool = False,
) -> int:
"""Find every Gemma4TextExperts module in `model`, replace its fused 3D
weights with two nn.ModuleList[Linear4bit] (per-expert), and patch
forward to dispatch per-expert.
Returns the count of swapped modules. Zero if the model has no Gemma-4
MoE experts or if transformers does not expose Gemma4TextExperts.
"""
try:
from transformers.models.gemma4.modeling_gemma4 import Gemma4TextExperts
except Exception:
return 0
swapped = 0
for module in model.modules():
if not isinstance(module, Gemma4TextExperts):
continue
# Idempotent: once swapped, the fused 3D Parameters are gone.
if hasattr(module, "_unsloth_gemma4_moe_4bit_swapped"):
continue
if not hasattr(module, "gate_up_proj") or not hasattr(module, "down_proj"):
continue
gate_up = module.gate_up_proj
down = module.down_proj
if not isinstance(gate_up, nn.Parameter) or gate_up.ndim != 3:
continue
if not isinstance(down, nn.Parameter) or down.ndim != 3:
continue
num_experts, two_intermediate, hidden = gate_up.shape
num_experts_d, hidden_d, intermediate = down.shape
if (
num_experts != num_experts_d
or hidden != hidden_d
or two_intermediate != 2 * intermediate
):
# Unrecognised layout: skip rather than risk corrupting weights.
if verbose:
print(
f"Unsloth: skipping Gemma4TextExperts swap due to "
f"unexpected shapes gate_up={tuple(gate_up.shape)} "
f"down={tuple(down.shape)}"
)
continue
device = gate_up.device
gate_up_list = nn.ModuleList()
down_list = nn.ModuleList()
for e in range(num_experts):
gu = _quantize_one_expert_to_linear4bit(
gate_up.data[e],
compute_dtype = compute_dtype,
quant_type = quant_type,
)
dp = _quantize_one_expert_to_linear4bit(
down.data[e],
compute_dtype = compute_dtype,
quant_type = quant_type,
)
gate_up_list.append(gu.to(device))
down_list.append(dp.to(device))
# Per-module peak = fused BF16 + accumulated per-expert nf4; released here.
del module.gate_up_proj
del module.down_proj
module.gate_up_proj_4bit = gate_up_list
module.down_proj_4bit = down_list
# Per-instance bind so sibling Gemma4TextExperts keep the class method.
# Forward variant ladder (most specific wins). STATIC_BF16 is the
# speed-ceiling variant; it overrides ACTIVE_ONLY/CACHE if set since
# those become no-ops once weights are kept permanently dequantized.
if (
is_gemma4_moe_4bit_grouped_enabled()
and is_gemma4_moe_4bit_grouped_static_bf16_enabled()
):
_fwd = _grouped_mm_forward_4bit_static_bf16
elif (
is_gemma4_moe_4bit_grouped_enabled()
and is_gemma4_moe_4bit_grouped_pt_dequant_enabled()
):
for L in list(module.gate_up_proj_4bit) + list(module.down_proj_4bit):
_ensure_pt_dequant_state(L)
_fwd = _grouped_mm_forward_4bit_pt_compiled
elif (
is_gemma4_moe_4bit_grouped_enabled()
and is_gemma4_moe_4bit_grouped_active_only_enabled()
and is_gemma4_moe_4bit_grouped_cached_enabled()
):
_fwd = _grouped_mm_forward_4bit_active_only_cached
elif (
is_gemma4_moe_4bit_grouped_enabled()
and is_gemma4_moe_4bit_grouped_active_only_enabled()
):
_fwd = _grouped_mm_forward_4bit_active_only
elif is_gemma4_moe_4bit_grouped_enabled():
_fwd = _grouped_mm_forward_4bit
else:
_fwd = _per_expert_forward
module.forward = MethodType(_fwd, module)
module._unsloth_gemma4_moe_4bit_swapped = True
swapped += 1
if swapped > 0 and torch.cuda.is_available():
# Free the cached fused tensors so post-swap VRAM reflects 4-bit.
torch.cuda.empty_cache()
return swapped

View file

@ -2478,6 +2478,16 @@ class FastLlamaModel:
and not _head.weight.is_floating_point()
):
_head.to(dtype)
# Guardrail: warn before dispatch hooks if quantization was silently dropped.
from unsloth.models.vision import _warn_if_quantization_silently_dropped
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = load_in_4bit,
load_in_8bit = kwargs.get("load_in_8bit", False),
full_finetuning = kwargs.get("full_finetuning", False),
quantization_config = kwargs.get("quantization_config"),
)
# Attach dispatch hooks for bnb multi-device loads.
from unsloth.models.vision import _attach_bnb_multidevice_hooks
@ -2500,9 +2510,19 @@ class FastLlamaModel:
attn_implementation = preferred_attn_impl,
**kwargs,
)
# Attach dispatch hooks for bnb multi-device loads.
from unsloth.models.vision import _attach_bnb_multidevice_hooks
# Guardrail (#5344) + multi-device dispatch hooks share an import.
from unsloth.models.vision import (
_warn_if_quantization_silently_dropped,
_attach_bnb_multidevice_hooks,
)
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = load_in_4bit,
load_in_8bit = kwargs.get("load_in_8bit", False),
full_finetuning = kwargs.get("full_finetuning", False),
quantization_config = kwargs.get("quantization_config"),
)
_attach_bnb_multidevice_hooks(
model,
load_in_4bit = load_in_4bit,

View file

@ -68,7 +68,6 @@ from unsloth_zoo.patching_utils import patch_model_and_tokenizer
from unsloth_zoo.training_utils import prepare_model_for_training
from unsloth_zoo.utils import Version
from transformers import __version__ as transformers_version
import types
import functools
@ -99,6 +98,140 @@ __all__ = [
]
# bnb-quantized Linear class names (see unslothai/unsloth#5344 guardrail).
_BNB_QUANT_CLASS_NAMES = ("Linear4bit", "Linear8bitLt", "LinearNF4", "LinearFP4")
# Substrings the guardrail treats as intentionally-not-quantized: embeddings,
# norms, biases, routers/gates that need fp16/fp32 precision, vision/audio
# towers, classification heads, rotary tables.
_GUARDRAIL_SKIP_PATTERNS = (
"embed",
"embedding",
"norm",
"ln_",
"rms",
".bias",
"lm_head",
"multi_modal_projector",
"merger",
"modality_projection",
"router",
"block_sparse_moe.gate",
"mamba",
"audio_tower",
"vision_tower",
"score",
"classifier",
"qa_outputs",
"rotary",
)
# A floating Parameter larger than this, found outside the skip list, counts
# as bulk weight that should have been 4-bit. Tuned so head dims and small
# projections do not false-fire but MoE fused expert tensors do.
_GUARDRAIL_BULK_WEIGHT_NUMEL = 8 * 1024 * 1024
def _warn_if_quantization_silently_dropped(
model,
load_in_4bit,
load_in_8bit,
full_finetuning,
quantization_config = None,
):
"""Guardrail for unslothai/unsloth#5344.
Two failure modes covered:
1. TOTAL bypass: load_in_4bit was requested but the model contains zero
bnb Linear4bit / Linear8bitLt modules. transformers / bnb / a backend
incompatibility dropped kwargs.quantization_config.
2. PARTIAL bypass: bnb quantized the nn.Linear modules but a large fraction
of weight bytes live in non-nn.Linear Parameters (e.g. Gemma-4 MoE fused
3D expert tensors, custom Linear-like wrappers). bnb only swaps nn.Linear
instances; fused expert weights stay in BF16, defeating QLoRA savings
even though some Linear4bit modules exist.
Warns rather than raises so non-bnb backends (CPU / MLX / AMD-without-bnb)
with legitimately no Linear4bit are not broken.
"""
if full_finetuning:
return
if quantization_config is not None:
if isinstance(quantization_config, dict):
load_in_4bit = load_in_4bit or bool(quantization_config.get("load_in_4bit"))
load_in_8bit = load_in_8bit or bool(quantization_config.get("load_in_8bit"))
else:
load_in_4bit = load_in_4bit or bool(
getattr(quantization_config, "load_in_4bit", False)
)
load_in_8bit = load_in_8bit or bool(
getattr(quantization_config, "load_in_8bit", False)
)
if not (load_in_4bit or load_in_8bit):
return
has_bnb = any(type(m).__name__ in _BNB_QUANT_CLASS_NAMES for m in model.modules())
# Failure mode 1: total bypass.
if not has_bnb:
kind = "4bit" if load_in_4bit else "8bit"
bnb_class_name = "Linear4bit" if load_in_4bit else "Linear8bitLt"
warnings.warn(
f"Unsloth: load_in_{kind}=True was requested but no bitsandbytes "
f"{bnb_class_name} modules were produced. The runtime quantization "
f"config was silently dropped and the model is in full precision. "
f"See https://github.com/unslothai/unsloth/issues/5344 for known "
f"triggers (transformers/bnb version mismatch, MoE checkpoints "
f"without a -bnb-4bit sibling, multi-GPU dispatch). Workaround: "
f'pass device_map="cuda:0" and pin transformers/bitsandbytes to '
f"a version known to work.",
stacklevel = 3,
)
return
# Failure mode 2: partial bypass. Walk named_parameters and find large
# floating tensors outside the skip list. If they aggregate to >= 2x the
# quantized payload, partial quant is essentially negating 4-bit savings.
quantized_bytes = 0
suspect_bytes = 0
suspect_samples = []
for name, p in model.named_parameters():
if p is None:
continue
nbytes = p.numel() * p.element_size()
if p.dtype in (torch.uint8, torch.int8):
# bnb stores 4-bit payloads as uint8 and 8-bit payloads (Int8Params) as int8.
quantized_bytes += nbytes
continue
if p.dtype not in (torch.bfloat16, torch.float16, torch.float32):
continue
if p.numel() < _GUARDRAIL_BULK_WEIGHT_NUMEL:
continue
lname = name.lower()
if any(pat in lname for pat in _GUARDRAIL_SKIP_PATTERNS):
continue
suspect_bytes += nbytes
if len(suspect_samples) < 3:
suspect_samples.append((name, str(p.dtype), tuple(p.shape)))
if quantized_bytes > 0 and suspect_bytes >= 2 * quantized_bytes:
kind = "4bit" if load_in_4bit else "8bit"
suspect_human = ", ".join(f"{n} ({d}, {s})" for n, d, s in suspect_samples)
warnings.warn(
f"Unsloth: load_in_{kind}=True is partially applied. "
f"bitsandbytes quantized ~{quantized_bytes/1024**3:.2f} GB of "
f"nn.Linear weights, but ~{suspect_bytes/1024**3:.2f} GB of "
f"non-nn.Linear floating Parameters were left unquantized (e.g. "
f"fused MoE expert tensors, custom Linear-like wrappers). "
f"Examples: {suspect_human}. The model's effective VRAM "
f"footprint is close to its full-precision size. See "
f"https://github.com/unslothai/unsloth/issues/5344.",
stacklevel = 3,
)
def _infer_device_map_from_loaded_model(model):
"""Build a compact device_map by inspecting actual parameter placements."""
device_map = {}
@ -911,6 +1044,82 @@ class FastBaseModel:
verify_fp8_support_if_applicable(model_config)
# Resolve 4-bit + Gemma4 swap parameters once (shared by both load paths).
_user_qcfg = kwargs.get("quantization_config", None)
if isinstance(_user_qcfg, dict):
_qcfg_4bit = bool(_user_qcfg.get("load_in_4bit", False))
_qcfg_dtype = _user_qcfg.get("bnb_4bit_compute_dtype", None)
_qcfg_quant_type = _user_qcfg.get("bnb_4bit_quant_type", None)
elif _user_qcfg is not None:
_qcfg_4bit = bool(getattr(_user_qcfg, "load_in_4bit", False))
_qcfg_dtype = getattr(_user_qcfg, "bnb_4bit_compute_dtype", None)
_qcfg_quant_type = getattr(_user_qcfg, "bnb_4bit_quant_type", None)
else:
_qcfg_4bit = False
_qcfg_dtype = None
_qcfg_quant_type = None
if isinstance(_qcfg_dtype, str):
_qcfg_dtype_str = _qcfg_dtype.removeprefix("torch.")
_maybe_dtype = getattr(torch, _qcfg_dtype_str, None)
_qcfg_dtype = (
_maybe_dtype if isinstance(_maybe_dtype, torch.dtype) else None
)
_effective_load_in_4bit = bool(load_in_4bit) or _qcfg_4bit
def _maybe_swap_gemma4_moe_4bit(_target_model):
if not (_effective_load_in_4bit and not full_finetuning):
return
try:
from unsloth.models.gemma4_moe_4bit import (
is_gemma4_moe_4bit_enabled,
swap_gemma4_experts_to_per_expert_linear4bit,
)
if not is_gemma4_moe_4bit_enabled():
return
if bnb_config is not None:
_compute_dtype = bnb_config.bnb_4bit_compute_dtype
_quant_type = getattr(bnb_config, "bnb_4bit_quant_type", "nf4")
else:
_compute_dtype = (
_qcfg_dtype if _qcfg_dtype is not None else torch.bfloat16
)
_quant_type = (
_qcfg_quant_type if _qcfg_quant_type is not None else "nf4"
)
_swapped = swap_gemma4_experts_to_per_expert_linear4bit(
_target_model,
compute_dtype = _compute_dtype,
quant_type = _quant_type,
)
if _swapped > 0:
print(
f"Unsloth: swapped {_swapped} "
f"Gemma4TextExperts module(s) to per-expert "
f"Linear4bit (see "
f"https://github.com/unslothai/unsloth/issues/5344)."
)
except Exception as _e:
_partial = sum(
1
for _m in _target_model.modules()
if getattr(_m, "_unsloth_gemma4_moe_4bit_swapped", False)
)
if _partial:
raise RuntimeError(
f"Unsloth: Gemma-4 MoE 4-bit swap failed after "
f"converting {_partial} module(s); model is in a "
f"mixed 4-bit/BF16 state. Reload the model to "
f"recover. Original error: "
f"{type(_e).__name__}: {_e}"
) from _e
warnings.warn(
f"Unsloth: Gemma-4 MoE 4-bit swap failed: "
f"{type(_e).__name__}: {_e}. Falling back to BF16 "
f"experts. Unset UNSLOTH_GEMMA4_MOE_4BIT to silence.",
stacklevel = 2,
)
raise_handler = RaiseUninitialized()
if not fast_inference:
# Prevent load_in_fp8 from being forwarded into HF internal model loading
@ -935,6 +1144,16 @@ class FastBaseModel:
# attn_implementation = attn_implementation,
**kwargs,
)
_maybe_swap_gemma4_moe_4bit(model)
# Guardrail: see _warn_if_quantization_silently_dropped + #5344.
_warn_if_quantization_silently_dropped(
model,
load_in_4bit = load_in_4bit,
load_in_8bit = load_in_8bit,
full_finetuning = full_finetuning,
quantization_config = kwargs.get("quantization_config"),
)
# Attach dispatch hooks for bnb multi-device loads.
_attach_bnb_multidevice_hooks(
model,
@ -1049,6 +1268,7 @@ class FastBaseModel:
bnb_config,
is_vision_model = is_vlm,
)
_maybe_swap_gemma4_moe_4bit(model)
model.vllm_engine = llm
model.fast_generate = model.vllm_engine.generate
model.fast_generate_batches = functools.partial(