unsloth/scripts/benchmarks/gemma4_flex_inference.py
Daniel Han d5a4ee22ad benchmarks: gemma4_flex_inference -- per-layer-type sliding window mask
The first cut routed every non-shared layer through one causal block mask
and relied on SDPA with is_causal=True for shared layers. That gives the
right semantics for Gemma-4's full_attention layers but silently drops the
sliding_attention window, so sliding layers attend far beyond their
512-token window as soon as the prefix grows.

This commit builds a block mask per attention regime (full_attention is
pure causal; sliding_attention is causal AND q_pos - kv_pos < window) and
passes both into each attention call as a dict, letting the patched
forward select by self.layer_type. For the shared-KV sidecar path, the
SDPA call now receives an explicit attn_mask composed the same way, so
sliding shared layers also respect the window. Strict less-than
comparison matches Unsloth's flex-attention convention for GPT-OSS.

`_causal_blockmask_with_window` and `_prefill_blockmask_with_window` are
local to this file; the shared helpers in `flex_paged_attention.py` stay
untouched. `FlexGemma4Inference` now caches both logical decode masks,
slices both per-row in `_decode_block_mask`, and runs the PageTable's
logical->physical conversion on each before passing them down.
2026-04-21 08:59:54 +00:00

1197 lines
46 KiB
Python

"""Gemma-4-E2B-it inference with flex_attention + paged KV cache + CUDA graphs.
Extends the Qwen3/Llama-3.2 engine in `qwen3_flex_inference.py` to a third
architecture, `unsloth/gemma-4-E2B-it`. Gemma-4 is not a drop-in addition:
its text backbone diverges from Qwen3/Llama in ways that cannot be folded
into a single `hasattr(self, "q_norm")` branch. The divergences, and how
this file handles them:
1. KV-sharing layers. E2B has 35 layers; the upper 20 (layers 15-34) lack
`k_proj`, `v_proj`, `k_norm`, `v_norm` entirely and consume the full
prefix K/V produced by a "store" layer further up the stack. Paging
the KV for shared layers would cost more than it saves, so the shared
layers read from a pre-sized sidecar dict
(`FlexGemma4Inference.shared_kv_buffer`) whose tensors live at fixed
device addresses for CUDA graph safety.
2. Dual attention types. Each layer is either `full_attention`
(`head_dim=512`, `rope_theta=1e6`, `partial_rotary_factor=0.25`) or
`sliding_attention` (`head_dim=256`, `rope_theta=10000`,
`sliding_window=512`). We precompute both (cos, sin) pairs once per
forward and dispatch on `self.layer_type`.
3. Per-layer input embeddings. `embed_tokens_per_layer` produces a
`[B, S, num_layers, 256]` auxiliary table that enters every layer
through a `per_layer_input_gate -> act -> mul -> per_layer_projection
-> post_per_layer_input_norm -> +residual` path after the MLP residual.
4. Four norms per layer. `input_layernorm` / `post_attention_layernorm`
wrap the attention block (double residual); `pre_feedforward_layernorm`
/ `post_feedforward_layernorm` wrap the MLP (double residual). A scalar
`layer_scalar` multiplies hidden_states at layer end.
5. Final logit softcap. `logits = tanh(logits / 30.0) * 30.0` applied on
the lm_head output.
The engine is text-only: `Gemma4ForCausalLM(text_config)` skips the
multimodal `Gemma4ForConditionalGeneration` wrapper and its vision + audio
towers entirely. Shared helpers (`PagedKVCache`, `PageTable`, `Sequence`,
`refresh_lora_merge_from_pristine`, `run_drift_verification`,
`flex_attention_compiled`, `_apply_rotary`, FA4 capability guard) are
imported from `qwen3_flex_inference.py` unchanged.
Run:
CUDA_VISIBLE_DEVICES=6 python scripts/benchmarks/gemma4_flex_inference.py \
--n_prompts 64 --max_new_tokens 512 --capture_cudagraph \
--stats_path logs/flex_gemma4_bf16.json
Requires `transformers>=5.5.0` (the `gemma4` module). The main workspace
env stays on 4.57.6; this file short-circuits with a clear install hint
if the module is missing. Use `isolated_run.py` with
`--extra_packages "transformers>=5.5.0 peft datasets"` to run on that env.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
import time
import types
from collections import deque
from pathlib import Path
from typing import Optional
import torch
import torch.nn.functional as F
from torch.nn.attention.flex_attention import BlockMask
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
# Shared helpers from qwen3_flex_inference.py. Import-time cost is paid once;
# we do not re-define any of these locally.
from qwen3_flex_inference import ( # noqa: E402
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
_apply_rotary,
_hash_state_dict,
_lora_needs_peft_fallback,
flex_attention_compiled,
refresh_lora_merge_from_pristine,
run_drift_verification,
)
from flex_paged_attention import PagedKVCache, PageTable # noqa: E402
from torch.nn.attention.flex_attention import create_block_mask as _create_block_mask # noqa: E402
# --- sliding-window block mask helpers ------------------------------------
#
# Gemma-4's `sliding_attention` layers attend only to the last
# `sliding_window` KV positions (`q_idx - kv_idx < W`) in addition to the
# standard causal mask. The shared helpers in `flex_paged_attention.py`
# only expose the pure-causal builders, so we build sliding variants here
# (local to this file so that file is untouched).
def _causal_blockmask_with_window(B: int, L: int, block_size: int, window: int, device: str):
def causal_windowed(b, h, q_idx, kv_idx):
return (q_idx >= kv_idx) & (q_idx - kv_idx < window)
return _create_block_mask(
causal_windowed,
B = B,
H = None,
Q_LEN = L,
KV_LEN = L,
BLOCK_SIZE = block_size,
device = device,
)
def _prefill_blockmask_with_window(batch_idx: torch.Tensor, block_size, window: int):
assert batch_idx.ndim == 2 and batch_idx.shape[0] == 1
L = batch_idx.shape[1]
docs = batch_idx.view(-1)
def document_causal_windowed(b, h, q_idx, kv_idx):
causal_mask = q_idx >= kv_idx
window_mask = q_idx - kv_idx < window
document_mask = docs[q_idx] == docs[kv_idx]
return causal_mask & window_mask & document_mask
return _create_block_mask(
document_causal_windowed,
B = 1,
H = None,
Q_LEN = L,
KV_LEN = L,
BLOCK_SIZE = block_size,
)
# --- transformers version guard --------------------------------------------
#
# Gemma-4 lands in `transformers>=5.5.0`. The main workspace env is on
# 4.57.6, which keeps the Qwen3 + Llama paths in qwen3_flex_inference.py
# working unchanged. We defer the import to call time so `--help` still
# works on 4.57.6.
def _require_gemma4():
try:
from transformers.models.gemma4.modeling_gemma4 import Gemma4ForCausalLM
from transformers.models.gemma4.configuration_gemma4 import (
Gemma4Config,
Gemma4TextConfig,
)
except ImportError:
import transformers
raise SystemExit(
f"Gemma-4 requires transformers>=5.5.0 (`gemma4` module). "
f"Current: transformers=={transformers.__version__}. "
f"Install: uv pip install 'transformers>=5.5.0'"
)
return Gemma4ForCausalLM, Gemma4Config, Gemma4TextConfig
# --- attention forward factory --------------------------------------------
def _apply_rotary_q(q, cos, sin):
"""Rotary on Q alone; used when K comes from the shared sidecar and
already carries its rotary from the store layer."""
cos = cos.unsqueeze(1)
sin = sin.unsqueeze(1)
def rotate_half(x):
x1 = x[..., : x.shape[-1] // 2]
x2 = x[..., x.shape[-1] // 2 :]
return torch.cat((-x2, x1), dim = -1)
return (q * cos) + (rotate_half(q) * sin)
def make_flex_gemma4_attention_forward(
page_table: PageTable, shared_kv_buffer: dict
):
"""Return a new `forward` method for `Gemma4TextAttention` that routes
through flex_attention against either the paged cache (non-shared
layers) or the full-length shared KV sidecar.
Three layer kinds:
- shared (`self.is_kv_shared_layer == True`): no `k_proj`/`v_proj`/
`k_norm`/`v_norm`. Read K/V from
`shared_kv_buffer[self.kv_shared_layer_index]`.
- store (`self.store_full_length_kv == True`): standard q/k/v
projection. After rotary, write the full-sequence K/V
into `shared_kv_buffer[self.layer_idx]` on prefill.
Also updates the paged cache for its own attention.
- plain (neither flag set): standard q/k/v + paged cache.
Shared layers attend over the prefix K/V only (populated at prefill).
Decode-time tokens generated by store layers are NOT written into the
sidecar -- this is the documented simplification from the plan; it
trades exactness during decode for a fixed-address sidecar that is
safe under CUDA graph capture.
`position_embeddings` is a dict keyed by `layer_type`; we pick the
right (cos, sin) pair before rotary.
Expects `self._paged_cache` to be set on non-shared layers (None on
shared layers). Shared layers still keep `self.q_proj`, `self.q_norm`.
`self.v_proj` may be None when the config sets `attention_k_eq_v`
(global head dim with shared K=V); that branch reuses k_states.
"""
def forward(
self,
hidden_states: torch.Tensor,
position_embeddings: dict,
attention_mask = None,
past_key_values = None,
cache_position = None,
flex_block_mask: Optional[dict] = None,
flex_input_pos: Optional[torch.Tensor] = None,
flex_batch_idx: Optional[torch.Tensor] = None,
flex_kernel_options: Optional[dict] = None,
**kwargs,
):
input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, self.head_dim)
cos, sin = position_embeddings[self.layer_type]
q = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
if getattr(self, "is_kv_shared_layer", False):
# Shared layer: read sidecar written by the paired store layer
# during prefill. Q still goes through rotary. The sidecar
# layout ([B, n_kv, max_seq, D]) does not match the paged
# cache's block-mask shape, so we route shared layers through
# the eager SDPA kernel instead of flex_attention. This is
# slower per-layer but keeps the sidecar design simple and
# CUDA-graph safe.
q = _apply_rotary_q(q, cos, sin)
shared_k, shared_v = shared_kv_buffer[self.kv_shared_layer_index]
B = q.shape[0]
k = shared_k[:B]
v = shared_v[:B]
Hq = q.shape[1]
Hkv = k.shape[1]
if Hq != Hkv:
groups = Hq // Hkv
k = k.repeat_interleave(groups, dim = 1)
v = v.repeat_interleave(groups, dim = 1)
# Build the attn_mask matching this layer's regime:
# - full_attention : pure causal.
# - sliding_attn : causal AND q_pos - kv_pos < window.
# For prefill (q_len > 1) the mask is a [q_len, kv_len] bool;
# for decode (q_len == 1) it becomes a [1, kv_len] row where
# the single q position is `input_pos` (passed in
# flex_input_pos) and kv_positions run 0..kv_len-1.
q_len = q.shape[-2]
kv_len = k.shape[-2]
window = getattr(self, "sliding_window", None)
if q_len > 1:
# Prefill: q_len == prefill_packed_len, kv_len should equal
# q_len in a clean run. Build per-batch positions.
q_pos = torch.arange(q_len, device = q.device)
kv_pos = torch.arange(kv_len, device = q.device)
attn_mask = q_pos[:, None] >= kv_pos[None, :]
if window is not None:
attn_mask = attn_mask & (q_pos[:, None] - kv_pos[None, :] < window)
else:
# Decode: q_pos per batch comes from flex_input_pos.
q_pos = flex_input_pos.view(B, 1) # [B, 1]
kv_pos = torch.arange(kv_len, device = q.device)[None, :]
attn_mask = q_pos >= kv_pos
if window is not None:
attn_mask = attn_mask & (q_pos - kv_pos < window)
# SDPA expects mask shape [B, 1, 1, kv_len].
attn_mask = attn_mask.unsqueeze(1).unsqueeze(1)
attn_output = F.scaled_dot_product_attention(
q,
k,
v,
attn_mask = attn_mask,
scale = self.scaling,
)
attn_output = (
attn_output.transpose(1, 2).reshape(*input_shape, -1).contiguous()
)
return self.o_proj(attn_output), None
else:
k_raw = self.k_proj(hidden_states).view(hidden_shape)
k = self.k_norm(k_raw).transpose(1, 2)
# `v_proj` may be None under Gemma-4's K=V global-attention
# option; in that case reuse the raw (un-normed) k projection.
if self.v_proj is not None:
v = self.v_norm(
self.v_proj(hidden_states).view(hidden_shape)
).transpose(1, 2)
else:
v = k_raw.transpose(1, 2)
q, k = _apply_rotary(q, k, cos, sin)
# Prefill-only sidecar write for store layers.
if getattr(self, "store_full_length_kv", False):
is_prefill = q.shape[-2] > 1
if is_prefill:
shared_k, shared_v = shared_kv_buffer[self.layer_idx]
B = k.shape[0]
S = k.shape[-2]
shared_k[:B, :, :S, :].copy_(k)
shared_v[:B, :, :S, :].copy_(v)
if self._paged_cache is not None and flex_input_pos is not None:
k, v = self._paged_cache.update(flex_input_pos, k, v, flex_batch_idx)
# flex_block_mask is a dict keyed by layer_type; pick the one
# matching this layer's regime (full_attention vs sliding_attention).
block_mask = flex_block_mask[self.layer_type]
attn_output = flex_attention_compiled(
q,
k,
v,
scale = self.scaling,
block_mask = block_mask,
enable_gqa = True,
kernel_options = flex_kernel_options,
)
attn_output = attn_output.transpose(1, 2).reshape(*input_shape, -1).contiguous()
return self.o_proj(attn_output), None
return forward
def patch_gemma4_attention_forwards(
model: torch.nn.Module, page_table: PageTable, shared_kv_buffer: dict
):
"""Attach a PagedKVCache to every non-shared attention layer and swap
in the flex_attention forward above. Shared layers get `_paged_cache =
None` because their K/V comes from the sidecar.
"""
fwd = make_flex_gemma4_attention_forward(page_table, shared_kv_buffer)
for layer in model.model.layers:
attn = layer.self_attn
if getattr(attn, "is_kv_shared_layer", False):
attn._paged_cache = None
else:
n_kv = attn.k_proj.out_features // attn.head_dim
attn._paged_cache = PagedKVCache(
page_table,
n_heads = n_kv,
head_dim = attn.head_dim,
dtype = model.dtype,
).to(model.device)
attn.forward = types.MethodType(fwd, attn)
# --- model forward walker --------------------------------------------------
def call_gemma4_model_with_flex_kwargs(
model, input_ids, position_ids, flex_kwargs
):
"""Walk the Gemma-4 text model manually so we can inject flex_* kwargs
into each attention call. Mirrors `call_model_with_flex_kwargs` in
`qwen3_flex_inference.py` but:
- precomputes both (cos, sin) variants and passes them as a dict
keyed by `layer.self_attn.layer_type`;
- materializes `per_layer_inputs` via the model's own
`get_per_layer_inputs` + `project_per_layer_inputs` helpers;
- runs the double-residual around attn, the double-residual around
MLP, the per-layer-input path, and the `layer_scalar` multiply.
The final norm is applied here; lm_head + softcap is applied by the
caller (so we can slice to logits_positions before the vocab matmul).
"""
base = model.model
inputs_embeds = base.embed_tokens(input_ids)
# Per-layer input table. E2B has hidden_size_per_layer_input = 256 and
# 35 layers, so this is [B, S, 35, 256] -- a local tensor with fixed
# shape across CUDA graph replays (input_ids is pre-allocated upstream).
per_layer_inputs = None
if getattr(base, "hidden_size_per_layer_input", 0):
per_layer_inputs = base.get_per_layer_inputs(input_ids, inputs_embeds)
per_layer_inputs = base.project_per_layer_inputs(
inputs_embeds, per_layer_inputs
)
position_embeddings = {
layer_type: base.rotary_emb(inputs_embeds, position_ids, layer_type)
for layer_type in base.unique_layer_types
}
hidden_states = inputs_embeds
for i, layer in enumerate(base.layers):
residual = hidden_states
hidden_states = layer.input_layernorm(hidden_states)
hidden_states, _ = layer.self_attn(
hidden_states,
position_embeddings = position_embeddings,
**flex_kwargs,
)
hidden_states = layer.post_attention_layernorm(hidden_states)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = layer.pre_feedforward_layernorm(hidden_states)
hidden_states = layer.mlp(hidden_states)
hidden_states = layer.post_feedforward_layernorm(hidden_states)
hidden_states = residual + hidden_states
if per_layer_inputs is not None and hasattr(layer, "per_layer_input_gate"):
residual = hidden_states
hidden_states = layer.per_layer_input_gate(hidden_states)
hidden_states = layer.act_fn(hidden_states)
hidden_states = hidden_states * per_layer_inputs[:, :, i, :]
hidden_states = layer.per_layer_projection(hidden_states)
hidden_states = layer.post_per_layer_input_norm(hidden_states)
hidden_states = residual + hidden_states
if hasattr(layer, "layer_scalar"):
hidden_states = hidden_states * layer.layer_scalar
hidden_states = base.norm(hidden_states)
return hidden_states
# --- inference engine ------------------------------------------------------
class FlexGemma4Inference:
def __init__(
self,
model,
tokenizer,
max_batch_size = 32,
max_seq_length = 2048,
n_pages = 2048,
page_size = 128,
max_new_tokens = 512,
decode_kernel_options = None,
prefill_kernel_options = None,
fa4_prefill = None,
base_model = None,
peft_model = None,
):
assert max_seq_length % page_size == 0
self.model = model
self.tokenizer = tokenizer
self.device = model.device
self.eos_token_id = tokenizer.eos_token_id
self.base_model = base_model
self.peft_model = peft_model
self.max_batch_size = max_batch_size
self.max_seq_length = max_seq_length
self.page_size = page_size
self.max_new_tokens = max_new_tokens
if fa4_prefill is None or fa4_prefill:
major, _ = torch.cuda.get_device_capability(self.device)
supported = major >= 9
if fa4_prefill and not supported:
import warnings
warnings.warn(
f"--fa4_prefill needs Hopper (sm_90) or Blackwell "
f"(sm_100 / sm_120); found sm_{major}0. Falling back "
f"to the Triton flex_attention backend.",
RuntimeWarning,
stacklevel = 2,
)
fa4_prefill = supported
self.fa4_prefill = fa4_prefill
self.prefill_q_block = 256 if fa4_prefill else 128
self.prefill_kv_block = 128
self.decode_kernel_options = (
decode_kernel_options
if decode_kernel_options is not None
else DECODE_KERNEL_OPTIONS_DEFAULT
)
base_prefill_opts = (
prefill_kernel_options
if prefill_kernel_options is not None
else dict(PREFILL_KERNEL_OPTIONS_DEFAULT)
)
if fa4_prefill:
base_prefill_opts = dict(base_prefill_opts)
base_prefill_opts.pop("FORCE_USE_FLEX_ATTENTION", None)
base_prefill_opts["BACKEND"] = "FLASH"
self.prefill_kernel_options = base_prefill_opts
self.page_table = PageTable(
n_pages = n_pages,
page_size = page_size,
max_batch_size = max_batch_size,
device = self.device.type,
)
# Allocate shared-KV sidecar buffers. Only "store" layers get an
# entry; shared layers read by the store layer's `layer_idx`.
# Buffers live at fixed device addresses, so CUDA graph replay
# reads stable pointers -- safe because store layers only write
# during prefill (not inside captured decode graphs) and shared
# layers only read.
#
# Per-layer num_kv_heads is derived from `k_proj.out_features /
# head_dim` rather than `config.num_key_value_heads`, because
# global-attention layers may use `num_global_key_value_heads`
# under `attention_k_eq_v`.
self.shared_kv_buffer: dict = {}
for i, layer in enumerate(model.model.layers):
attn = layer.self_attn
if not getattr(attn, "store_full_length_kv", False):
continue
hd = attn.head_dim
n_kv = attn.k_proj.out_features // hd
K = torch.zeros(
max_batch_size,
n_kv,
max_seq_length,
hd,
dtype = model.dtype,
device = self.device,
)
V = torch.zeros_like(K)
self.shared_kv_buffer[i] = (K, V)
patch_gemma4_attention_forwards(
model, self.page_table, self.shared_kv_buffer
)
self.input_pos_buffer = torch.zeros(
max_batch_size, dtype = torch.int32, device = self.device
)
# Detect the sliding window from any sliding-attention layer.
# We need one block mask per layer type: `full_attention` is pure
# causal; `sliding_attention` is causal AND q_pos - kv_pos < window.
sliding_window = None
for layer in model.model.layers:
if getattr(layer.self_attn, "is_sliding", False):
sliding_window = layer.self_attn.sliding_window
break
self.sliding_window = sliding_window
self.block_mask_logical_by_type = {
"full_attention": self.page_table.create_causal_blockmask(
B = max_batch_size, L = max_seq_length
),
}
if sliding_window is not None:
self.block_mask_logical_by_type["sliding_attention"] = (
_causal_blockmask_with_window(
B = max_batch_size,
L = max_seq_length,
block_size = page_size,
window = sliding_window,
device = self.device.type,
)
)
# Legacy alias used by the original page-aware decode slicer.
self.block_mask_logical = self.block_mask_logical_by_type["full_attention"]
self.cudagraph_captured = False
self.graphs = {}
self.graph_vars = {}
def tokenize(self, sequences):
for seq in sequences:
ids = self.tokenizer(seq.text, return_tensors = "pt")["input_ids"].squeeze(0)
seq.input_ids = ids
seq.input_length = ids.shape[0]
def _softcap(self, logits):
sc = getattr(self.model.config, "final_logit_softcapping", None)
if sc is not None and sc > 0:
logits = torch.tanh(logits / sc) * sc
return logits
def _prefill(self, batch: list) -> torch.Tensor:
input_ids_list = [seq.input_ids.to(self.device) for seq in batch]
input_pos_list = [
torch.arange(seq.input_length, dtype = torch.long, device = self.device)
for seq in batch
]
batch_idx_list = [
torch.full(
(seq.input_length,),
seq.batch_idx,
dtype = torch.long,
device = self.device,
)
for seq in batch
]
input_ids = torch.cat(input_ids_list).view(1, -1)
input_pos = torch.cat(input_pos_list).view(1, -1)
batch_idx = torch.cat(batch_idx_list).view(1, -1)
L = input_ids.shape[1]
q_block = self.prefill_q_block
pad = (q_block - L % q_block) % q_block
if pad > 0:
input_ids = F.pad(input_ids, (0, pad), value = 0)
input_pos = F.pad(input_pos, (0, pad), value = 0)
batch_idx = F.pad(batch_idx, (0, pad), value = 0)
input_lengths = torch.tensor(
[s.input_length for s in batch], dtype = torch.long, device = self.device
)
logits_positions = input_lengths.cumsum(dim = 0) - 1
prefill_block_size = (
(self.prefill_q_block, self.prefill_kv_block)
if self.fa4_prefill
else self.prefill_q_block
)
mask_full = self.page_table.create_prefill_blockmask_no_paging(
batch_idx, BLOCK_SIZE = prefill_block_size
)
masks_by_type = {"full_attention": mask_full}
if self.sliding_window is not None:
masks_by_type["sliding_attention"] = _prefill_blockmask_with_window(
batch_idx,
block_size = prefill_block_size,
window = self.sliding_window,
)
flex_kwargs = dict(
flex_block_mask = masks_by_type,
flex_input_pos = input_pos,
flex_batch_idx = batch_idx,
flex_kernel_options = self.prefill_kernel_options,
)
hidden = call_gemma4_model_with_flex_kwargs(
self.model, input_ids, input_pos, flex_kwargs
)
logits = self.model.lm_head(hidden[:, logits_positions, :]).squeeze(0)
return self._softcap(logits)
def _decode_block_mask(self, batch_idx: torch.Tensor):
"""Slice one row of the logical decode mask per sequence, for
both full and sliding regimes. Returns a dict keyed by layer_type
plus the raw `input_pos` tensor (needed for PageTable conversion)."""
input_pos = self.input_pos_buffer[batch_idx]
assert batch_idx.ndim == 1 and input_pos.ndim == 1
B = batch_idx.shape[0]
def _slice(block_mask, extra_mask_mod):
input_block_idx = input_pos // block_mask.BLOCK_SIZE[0]
kv_num_blocks = block_mask.kv_num_blocks[
batch_idx, :, input_block_idx
].view(B, 1, 1)
kv_indices = block_mask.kv_indices[
batch_idx, :, input_block_idx
].view(B, 1, 1, -1)
full_num = full_idx = None
if block_mask.full_kv_num_blocks is not None:
full_num = block_mask.full_kv_num_blocks[
batch_idx, :, input_block_idx
].view(B, 1, 1)
full_idx = block_mask.full_kv_indices[
batch_idx, :, input_block_idx
].view(B, 1, 1, -1)
seq_length = (1, block_mask.seq_lengths[1])
return BlockMask.from_kv_blocks(
kv_num_blocks,
kv_indices,
full_num,
full_idx,
BLOCK_SIZE = block_mask.BLOCK_SIZE,
mask_mod = extra_mask_mod,
seq_lengths = seq_length,
)
def causal_offset(off):
def m(b, h, q_idx, kv_idx):
return q_idx + off[b] >= kv_idx
return m
def causal_offset_windowed(off, window):
def m(b, h, q_idx, kv_idx):
return (q_idx + off[b] >= kv_idx) & (
q_idx + off[b] - kv_idx < window
)
return m
masks = {
"full_attention": _slice(
self.block_mask_logical_by_type["full_attention"],
causal_offset(input_pos),
),
}
if self.sliding_window is not None:
masks["sliding_attention"] = _slice(
self.block_mask_logical_by_type["sliding_attention"],
causal_offset_windowed(input_pos, self.sliding_window),
)
return masks, input_pos
def _decode_step_eager(self, batch_idx: torch.Tensor, input_ids: torch.Tensor):
B = input_ids.shape[0]
masks, input_pos = self._decode_block_mask(batch_idx)
# Convert each regime's block mask through the page table so the
# logical→physical kv page mapping is correct for every layer.
masks = {
k: self.page_table.convert_logical_block_mask(m, batch_idx)
for k, m in masks.items()
}
position_ids = input_pos.view(B, 1).to(torch.long)
flex_kwargs = dict(
flex_block_mask = masks,
flex_input_pos = input_pos.view(B, 1).to(torch.long),
flex_batch_idx = batch_idx,
flex_kernel_options = self.decode_kernel_options,
)
hidden = call_gemma4_model_with_flex_kwargs(
self.model, input_ids.view(B, 1), position_ids, flex_kwargs
)
logits = self.model.lm_head(hidden[:, -1, :])
return self._softcap(logits)
def _decode_step(
self,
batch_idx: torch.Tensor,
input_ids: torch.Tensor,
input_pos: torch.Tensor,
):
self.input_pos_buffer.zero_()
self.input_pos_buffer[batch_idx] = input_pos
if not self.cudagraph_captured:
return self._decode_step_eager(batch_idx, input_ids)
bs = input_ids.size(0)
key = next(x for x in self.graph_bs if x >= bs)
graph = self.graphs[key]
gv = self.graph_vars
for k, v in gv.items():
if k != "outputs":
v.zero_()
gv["input_ids"][:bs] = input_ids
gv["batch_idx"][:bs] = batch_idx
graph.replay()
return gv["outputs"][:bs]
def capture_decode_cudagraph(self):
max_bs = self.max_batch_size
reserved_batches = []
for bi in range(1, max_bs):
try:
allocated = self.page_table.allocate()
self.page_table.reserve(
allocated,
torch.tensor(
[allocated], device = self.device, dtype = torch.long
),
self.page_size,
)
reserved_batches.append(allocated)
except Exception:
break
input_ids = torch.zeros(max_bs, dtype = torch.int64, device = self.device)
batch_idx = torch.arange(max_bs, dtype = torch.int64, device = self.device)
outputs = torch.zeros(
(max_bs, self.model.config.vocab_size),
dtype = self.model.dtype,
device = self.device,
)
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
pool = None
for bs in reversed(self.graph_bs):
if bs > max_bs:
continue
print(f"[flex-gemma4] capturing CUDA graph for bs={bs}")
torch.cuda.synchronize()
_ = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
torch.cuda.synchronize()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool):
outputs[:bs] = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
if pool is None:
pool = graph.pool()
self.graphs[bs] = graph
torch.cuda.synchronize()
for bi in reserved_batches:
self.page_table.erase(bi)
self.graph_vars = dict(
input_ids = input_ids, batch_idx = batch_idx, outputs = outputs
)
def refresh_inference_from_base(self):
if self.base_model is None or self.peft_model is None:
return 0
return refresh_lora_merge_from_pristine(self.base_model, self.peft_model)
@torch.inference_mode()
def generate(self, sequences, capture_cudagraph = False):
self.tokenize(sequences)
waiting = deque(sequences)
running = deque()
done = []
if capture_cudagraph and not self.cudagraph_captured:
self.capture_decode_cudagraph()
self.cudagraph_captured = True
while waiting or running:
batch = []
while waiting and self.page_table.can_reserve(waiting[0].total_length):
seq = waiting.popleft()
bi = self.page_table.allocate()
self.page_table.reserve(
bi,
torch.tensor([bi], device = self.device, dtype = torch.long),
seq.total_length,
)
seq.batch_idx = bi
batch.append(seq)
if batch:
logits = self._prefill(batch)
next_ids = torch.argmax(logits, dim = -1).tolist()
for i, seq in enumerate(batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
continue
decode_batch = []
while running:
seq = running.popleft()
if self.page_table.capacity[seq.batch_idx] >= seq.total_length:
decode_batch.append(seq)
elif self.page_table.can_reserve(
seq.total_length, batch_idx_int = seq.batch_idx
):
self.page_table.reserve(
seq.batch_idx,
torch.tensor(
[seq.batch_idx],
device = self.device,
dtype = torch.long,
),
seq.total_length,
)
decode_batch.append(seq)
else:
running.appendleft(seq)
newest = running.pop()
waiting.appendleft(newest)
self.page_table.erase(newest.batch_idx)
if not decode_batch:
continue
B = len(decode_batch)
bi_tensor = torch.tensor(
[s.batch_idx for s in decode_batch],
dtype = torch.long,
device = self.device,
)
last_ids = torch.tensor(
[s.last_token_id for s in decode_batch],
dtype = torch.long,
device = self.device,
)
cur_pos = torch.tensor(
[s.total_length - 1 for s in decode_batch],
dtype = torch.int32,
device = self.device,
)
logits = self._decode_step(bi_tensor, last_ids, cur_pos)
next_ids = torch.argmax(logits, dim = -1).tolist()
for i, seq in enumerate(decode_batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
return done
# --- CLI -------------------------------------------------------------------
def main():
p = argparse.ArgumentParser()
p.add_argument("--model_name", default = "unsloth/gemma-4-E2B-it")
p.add_argument("--n_prompts", type = int, default = 64)
p.add_argument("--n_rounds", type = int, default = 2)
p.add_argument("--max_new_tokens", type = int, default = 512)
p.add_argument("--max_batch_size", type = int, default = 64)
p.add_argument("--max_seq_length", type = int, default = 2048)
p.add_argument("--n_pages", type = int, default = 2048)
p.add_argument("--page_size", type = int, default = 128)
p.add_argument("--capture_cudagraph", action = "store_true")
p.add_argument("--lora_adapter", default = None)
p.add_argument("--decode_kernel_options", default = None)
p.add_argument("--prefill_kernel_options", default = None)
p.add_argument(
"--fa4_prefill",
default = None,
action = argparse.BooleanOptionalAction,
help = (
"Use BLOCK_SIZE=(256,128) + BACKEND=FLASH on prefill. Default "
"auto-enables on Hopper (sm_90) and Blackwell (sm_100, sm_120)."
),
)
p.add_argument("--load_in_4bit", action = "store_true")
p.add_argument(
"--no_merge_lora",
action = "store_true",
help = "Keep the LoRA adapter as a PEFT wrapper instead of merging.",
)
p.add_argument(
"--verify_no_drift",
action = "store_true",
help = "Drift-verify the double-copy LoRA refresh across N cycles.",
)
p.add_argument("--verify_iterations", type = int, default = 10)
p.add_argument("--model_name_4bit", default = None)
p.add_argument("--stats_path", required = True)
p.add_argument(
"--chat_template",
choices = ["auto", "grpo", "native"],
default = "auto",
help = (
"Which chat template to use. `auto`: native for Gemma-4. "
"`grpo`: force the GRPO template. `native`: force the "
"tokenizer's built-in template."
),
)
args = p.parse_args()
def _parse_opts(s):
if s is None:
return None
return json.loads(s)
Gemma4ForCausalLM, Gemma4Config, Gemma4TextConfig = _require_gemma4()
from transformers import AutoTokenizer
from transformers.models.gemma4.modeling_gemma4 import (
Gemma4ForConditionalGeneration,
)
tok = AutoTokenizer.from_pretrained(args.model_name)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
base_model = None
peft_model = None
if args.load_in_4bit:
from transformers import AutoModelForCausalLM
from huggingface_hub import HfApi
bnb_model_name = args.model_name_4bit or f"{args.model_name}-unsloth-bnb-4bit"
# Probe for the 4-bit shard. If missing, the user asked for a
# quant row we cannot produce; fail loudly rather than silently
# falling back to bf16 (which would mislabel the stats file).
try:
HfApi().model_info(bnb_model_name)
except Exception as e:
raise SystemExit(
f"[flex-gemma4] --load_in_4bit: 4-bit shard {bnb_model_name} "
f"is not available ({e}). Use --model_name_4bit to override "
f"or drop --load_in_4bit for bf16."
)
print(f"[flex-gemma4] loading 4-bit base: {bnb_model_name}")
model = AutoModelForCausalLM.from_pretrained(
bnb_model_name,
attn_implementation = "eager",
device_map = "cuda:0",
)
if getattr(model.config, "tie_word_embeddings", False):
model.lm_head.weight = model.model.embed_tokens.weight
model.eval()
if args.lora_adapter:
from peft import PeftModel
peft_wrapper = PeftModel.from_pretrained(
model,
str(Path(args.lora_adapter).resolve()),
is_trainable = False,
)
model = peft_wrapper.base_model.model
else:
# Text-only bf16. The checkpoint stores text weights under the
# `model.language_model.` prefix (Gemma-4 is natively multimodal).
# We load the full `Gemma4ForConditionalGeneration`, pluck the
# language_model, then drop the vision + audio towers before
# moving to GPU so peak memory reflects text only.
full_cfg = Gemma4Config.from_pretrained(args.model_name)
text_cfg = full_cfg.text_config
full_model = Gemma4ForConditionalGeneration.from_pretrained(
args.model_name,
dtype = torch.bfloat16,
attn_implementation = "eager",
)
lang_model = full_model.model.language_model
# Drop the non-text towers. `embed_vision` / `embed_audio` project
# from text hidden size -- harmless when not invoked, but we kill
# them too so deepcopy (below) stays cheap.
full_model.model.vision_tower = None
full_model.model.audio_tower = None
full_model.model.embed_vision = None
full_model.model.embed_audio = None
# Build a ForCausalLM shell around the language_model so LoRA /
# state-dict hashing treat it like any other HF decoder model.
base_model = Gemma4ForCausalLM(text_cfg)
base_model.model = lang_model
base_model.lm_head.weight = lang_model.embed_tokens.weight
base_model = base_model.to(torch.bfloat16).to("cuda")
base_model.eval()
del full_model
if not args.lora_adapter:
model = base_model
base_model = None
elif args.no_merge_lora:
from peft import PeftModel
peft_wrapper = PeftModel.from_pretrained(
base_model,
str(Path(args.lora_adapter).resolve()),
is_trainable = False,
)
model = peft_wrapper.base_model.model
base_model = None
else:
from peft import PeftModel
print("[flex-gemma4] deep-copying base model for double-copy rollout")
inference_model = copy.deepcopy(base_model)
inference_model.eval()
peft_model = PeftModel.from_pretrained(
inference_model,
str(Path(args.lora_adapter).resolve()),
is_trainable = False,
)
model = peft_model.base_model.model
model.eval()
if args.verify_no_drift:
if args.load_in_4bit:
raise SystemExit(
"--verify_no_drift only applies to the bf16 double-copy path."
)
if args.no_merge_lora:
raise SystemExit(
"--verify_no_drift is incompatible with --no_merge_lora."
)
if base_model is None or peft_model is None:
raise SystemExit(
"--verify_no_drift requires --lora_adapter against the bf16 path."
)
print(
f"[flex-gemma4] running drift verification: "
f"{args.verify_iterations} perturb+refresh cycles"
)
result = run_drift_verification(
base_model, peft_model, n_iters = args.verify_iterations
)
result = {"mode": "verify_no_drift", "model_name": args.model_name, **result}
os.makedirs(
os.path.dirname(os.path.abspath(args.stats_path)) or ".",
exist_ok = True,
)
with open(args.stats_path, "w") as f:
json.dump(result, f, indent = 2)
print(json.dumps(result, indent = 2))
os._exit(0)
from unsloth_grpo_common import SYSTEM_PROMPT, apply_chat_template_to_tokenizer
from datasets import load_dataset
if args.chat_template == "auto":
# Gemma-4 default is the tokenizer native template; only Qwen3
# in this repo uses GRPO-by-default.
use_grpo = False
elif args.chat_template == "grpo":
use_grpo = True
else:
use_grpo = False
if use_grpo:
apply_chat_template_to_tokenizer(tok)
print("[flex-gemma4] chat_template: GRPO")
else:
print("[flex-gemma4] chat_template: tokenizer native")
ds = load_dataset("open-r1/DAPO-Math-17k-Processed", "en", split = "train")
ds = ds.shuffle(seed = 3407).select(range(args.n_prompts))
messages = [
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": x["prompt"]},
]
for x in ds
]
texts = [
tok.apply_chat_template(m, add_generation_prompt = True, tokenize = False)
for m in messages
]
inference = FlexGemma4Inference(
model,
tok,
max_batch_size = args.max_batch_size,
max_seq_length = args.max_seq_length,
n_pages = args.n_pages,
page_size = args.page_size,
max_new_tokens = args.max_new_tokens,
decode_kernel_options = _parse_opts(args.decode_kernel_options),
prefill_kernel_options = _parse_opts(args.prefill_kernel_options),
fa4_prefill = args.fa4_prefill,
base_model = base_model,
peft_model = peft_model,
)
if inference.base_model is not None and inference.peft_model is not None:
n = inference.refresh_inference_from_base()
print(f"[flex-gemma4] double-copy rollout: refreshed {n} LoRA-target layers")
def make_seqs():
return [Sequence(text = t, max_new_tokens = args.max_new_tokens) for t in texts]
torch.cuda.reset_peak_memory_stats()
print("[flex-gemma4] warmup (16 prompts)...")
_ = inference.generate(make_seqs()[:16], capture_cudagraph = args.capture_cudagraph)
torch.cuda.synchronize()
wall_times = []
total_decoded = 0
for r in range(args.n_rounds):
torch.cuda.synchronize()
t0 = time.perf_counter()
out = inference.generate(make_seqs())
torch.cuda.synchronize()
wall_times.append(time.perf_counter() - t0)
total_decoded = sum(len(s.output_ids) for s in out)
print(
f"[flex-gemma4] round {r}: {wall_times[-1]:.2f}s, {total_decoded} "
f"tokens, {total_decoded / wall_times[-1]:.1f} tok/s"
)
med = sorted(wall_times)[len(wall_times) // 2]
best = min(wall_times)
peak = torch.cuda.max_memory_allocated() / 1024**3
sample_completions = []
for s in out[:3]:
sample_completions.append(
tok.decode(s.output_ids[:80], skip_special_tokens = True)
)
res = {
"backend": "flex-gemma4",
"model_name": args.model_name,
"capture_cudagraph": args.capture_cudagraph,
"lora_adapter": args.lora_adapter,
"n_prompts": args.n_prompts,
"n_decoded_tokens": total_decoded,
"wall_times_s": wall_times,
"median_wall_s": med,
"best_wall_s": best,
"decode_tps_median": total_decoded / med if med else 0,
"decode_tps_best": total_decoded / best if best else 0,
"max_new_tokens": args.max_new_tokens,
"peak_memory_gb": peak,
"sample_completions": sample_completions,
}
os.makedirs(
os.path.dirname(os.path.abspath(args.stats_path)) or ".", exist_ok = True
)
with open(args.stats_path, "w") as f:
json.dump(res, f, indent = 2)
print(json.dumps(res, indent = 2))
os._exit(0)
if __name__ == "__main__":
main()