unsloth/tests/flex_gemma4_parity.py
danielhanchen 749fb75bd0 flex: add Gemma 4 MoE inference support (bf16 + LoRA)
Extends flex fast-inference to `unsloth/gemma-4-26B-A4B-it` (30 layers,
128 experts top-k 8, H=2816, ~3.8B active of 25.2B). Mirrors the
FlexGptOssInference / FlexMoEInference template with Gemma 4-specific
wiring:

- Dual dense MLP + MoE per decoder layer (Gemma4TextMLP alongside
  Gemma4TextExperts; outputs summed before the residual add, then
  multiplied by the per-layer `layer_scalar` buffer).
- Per-layer sliding-window dispatch (25 sliding @ 1024 tokens, 5 full
  attention) via twin BlockMask built once per generate() entry.
- Two-tier RoPE: sliding layers use rope_theta=10K with full head_dim
  rotation; full-attn layers use rope_type=proportional with theta=1M
  and partial_rotary_factor=0.25 (the inv_freq's zero-padded tail makes
  the generic rotate_half a no-op on the unrotated dims, so a single
  rotary helper covers both).
- Per-head Q/K/V RMSNorm applied before RoPE / KV write.
- attention_k_eq_v=True on full-attn layers (v_proj is None): value is
  the raw k_proj output, followed only by v_norm (with_scale=False).
- Rebind Gemma4TextExperts.forward to forward_native_grouped_mm so
  decode uses the grouped_mm backend (the slow Python loop in the stock
  forward is neither fast nor CUDA-graph-capturable). The routing
  weights already include per_expert_scale via Gemma4TextRouter.forward,
  so no extra folding is needed.
- CUDA graph capture and UNSLOTH_FLEX_COMPILE_WALKER=1 inherit from the
  MoE template (single bucket ladder, single pool across buckets).

Arch detection:
- `_detect_arch` distinguishes dense vs MoE Gemma 4 via
  `text_config.num_experts > 1` (same class name covers both variants).
- `bind_peft_model` extends the MoE no-deepcopy shortcut to gemma4_moe.

Validation (B200, bf16, 3 chat prompts, 64 tokens):
- Dense 31B sanity check: coherent completion via existing
  FlexGemma4Inference.
- Flex (cudagraph) vs HF naive: 24/24, 2/2, 4/4 tokens bitwise match.
- Merge parity: 7/7 cases bitwise (rank 16 / 64, 1 + 2 adapters, bf16
  + fp32, E=128, 2I=1408, H=2816 / H, I=704).
- Throughput bs=8/16/32/48: 510 / 1030 / 1564 / 916 tok/s vs HF naive
  134 tok/s at bs=8 (3.8x-11.7x).
- GRPO smoke: DAPO-Math-17k, seed 3407, max_steps=5 — stable (see
  follow-up comment on PR).

Out of scope: E2B/E4B KV-shared + per-layer-input variants (guarded
with NotImplementedError); bnb-4bit stacked experts (no such class
ships for Gemma 4 today).
2026-04-23 08:28:53 +00:00

191 lines
6.2 KiB
Python

# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Token parity for FlexGemma4Inference / FlexGemma4MoEInference vs pure HF.
Usage::
CUDA_VISIBLE_DEVICES=2 UNSLOTH_FAST_INFERENCE=1 python -u \\
tests/flex_gemma4_parity.py --backend flex --model unsloth/gemma-4-31B-it
CUDA_VISIBLE_DEVICES=3 python -u tests/flex_gemma4_parity.py \\
--backend hf --model unsloth/gemma-4-31B-it
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
CHAT_PROMPTS = [
"In one sentence, what is Paris?",
"What is 23 + 19? Answer in one word.",
"Continue: The quick brown fox jumps over",
]
def _run_flex(args, dtype, *, capture: bool, lora_path=None):
import torch
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
import unsloth # noqa
from unsloth import FastLanguageModel
if not capture:
# Force the eager decode path across arches.
try:
from unsloth.inference.flex_gemma4 import FlexGemma4Inference
FlexGemma4Inference.capture_decode_cudagraph = lambda self: None
except Exception:
pass
try:
from unsloth.inference.flex_gemma4_moe import FlexGemma4MoEInference
FlexGemma4MoEInference.capture_decode_cudagraph = lambda self: None
except Exception:
pass
model, tok = FastLanguageModel.from_pretrained(
model_name=args.model,
max_seq_length=args.max_seq_length,
dtype=dtype,
load_in_4bit=False,
fast_inference=True,
max_batch_size=4,
gpu_memory_utilization=0.6,
)
if lora_path is not None:
model.load_adapter(lora_path, adapter_name="default")
print(f"[parity-flex] LoRA attached from {lora_path}")
prompts = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False,
add_generation_prompt=True,
)
for p in CHAT_PROMPTS[: args.num_prompts]
]
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
_ = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
outs = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
token_ids = [list(o.outputs[0].token_ids) for o in outs]
texts = [o.outputs[0].text for o in outs]
return token_ids, texts, tok
def _run_hf(args, dtype, *, lora_path=None):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(args.model)
try:
model = AutoModelForCausalLM.from_pretrained(
args.model, dtype=dtype, device_map="cuda",
attn_implementation="eager",
)
except Exception:
# Multimodal Gemma 4 (ConditionalGeneration) — load the top-level class.
from transformers import AutoModelForImageTextToText
model = AutoModelForImageTextToText.from_pretrained(
args.model, dtype=dtype, device_map="cuda",
attn_implementation="eager",
)
model.eval()
if lora_path is not None:
from peft import PeftModel
model = PeftModel.from_pretrained(model, lora_path)
model.eval()
print(f"[parity-hf] LoRA attached from {lora_path}")
if tok.pad_token_id is None:
tok.pad_token_id = tok.eos_token_id
tok.padding_side = "left"
prompts = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False,
add_generation_prompt=True,
)
for p in CHAT_PROMPTS[: args.num_prompts]
]
inputs = tok(prompts, return_tensors="pt", padding=True).to("cuda")
out = model.generate(
**inputs,
max_new_tokens=args.max_new_tokens,
do_sample=False,
temperature=1.0,
pad_token_id=tok.pad_token_id,
)
prompt_len = inputs["input_ids"].shape[1]
eos = tok.eos_token_id
pad = tok.pad_token_id
token_ids = []
texts = []
for row in out:
ids = row[prompt_len:].tolist()
while ids and ids[-1] in (eos, pad):
ids.pop()
token_ids.append(ids)
texts.append(tok.decode(ids, skip_special_tokens=False))
return token_ids, texts, tok
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="unsloth/gemma-4-31B-it")
p.add_argument("--backend", choices=["flex", "flex_eager", "hf"], required=True)
p.add_argument("--max_new_tokens", type=int, default=64)
p.add_argument("--num_prompts", type=int, default=3)
p.add_argument("--lora_path", default=None)
p.add_argument("--max_seq_length", type=int, default=1024)
p.add_argument("--out_dir", default="async_task_outputs/gemma4_moe_bench")
args = p.parse_args()
import torch
dtype = torch.bfloat16
if args.backend == "flex":
token_ids, texts, _ = _run_flex(args, dtype, capture=True, lora_path=args.lora_path)
elif args.backend == "flex_eager":
token_ids, texts, _ = _run_flex(args, dtype, capture=False, lora_path=args.lora_path)
else:
token_ids, texts, _ = _run_hf(args, dtype, lora_path=args.lora_path)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
model_tag = args.model.replace("/", "_")
suffix = "_lora" if args.lora_path else ""
out_path = out_dir / f"parity_{model_tag}_{args.backend}{suffix}.json"
with open(out_path, "w") as f:
json.dump(
{
"backend": args.backend,
"model": args.model,
"prompts": CHAT_PROMPTS[: args.num_prompts],
"token_ids": token_ids,
"texts": texts,
},
f,
indent=2,
)
print(f"[parity-{args.backend}] wrote {out_path}")
for i, (pp, tt) in enumerate(zip(CHAT_PROMPTS[: args.num_prompts], texts)):
print(f"[parity-{args.backend}] prompt {i}: {pp!r}")
print(f"[parity-{args.backend}] completion {i}: {tt!r}")
if __name__ == "__main__":
main()