A clean, standalone PyTorch implementation of Google's Gemma 4 model family — ported layer-by-layer from the official JAX reference with numerical verification at every step.
Text, vision, and audio. No mystery abstractions. Just PyTorch and matrix multiplies.
| Variant | Layers | Embed | Heads | KV Heads | Attention | Notable Features |
|---|---|---|---|---|---|---|
| E2B | 35 | 1536 | 8 | 1 | 4:1 local:global | PLI, KV sharing, vision, audio |
| E4B | 42 | 2560 | 8 | 2 | 5:1 local:global | PLI, KV sharing, vision, audio |
| 12B | 48 | 3840 | 16 | 8 (1 global) | 5:1 local:global | Encoder-free vision + audio, K=V global |
| 31B | 60 | 5376 | 32 | 16 (4 global) | 5:1 local:global | K=V global, bidirectional vision |
| 26B-A4B | 30 | 2816 | 16 | 8 (2 global) | 5:1 local:global | MoE (128 experts, top-8), K=V global, vision |
All five use QK-norm and V-norm.
The 12B (HF gemma4_unified) drops the multimodal towers entirely — there is no
SigLIP-style vision encoder and no conformer:
- Vision: raw 48×48 pixel patches (3×3 groups of 16px patches merged) → LayerNorm → Linear → LayerNorm → factorised 2-D position embedding → LayerNorm → RMSNorm → Linear. The spatial pooling the tower models apply after their encoder happens here instead, before any projection.
- Audio: raw 16 kHz waveform chunked into 640-sample frames (40 ms), each frame projected by RMSNorm → Linear. No mel spectrogram.
The text decoder is the same TransformerBlock as every other variant. Config
types are EncoderFreeVisionConfig / EncoderFreeAudioConfig; Composer and
preprocess_images handle both families automatically, and the encoder-free
audio path passes audio_frames where the tower path passes audio_mel.
pip install -e .Core dependencies are torch, sentencepiece, safetensors, and tokenizers.
Optional extras:
pip install -e ".[image]" # adds pillow (PIL image input)
pip install -e ".[audio]" # adds torchaudio (resampling non-16 kHz audio)
pip install -e ".[all]" # image + audio
pip install -e ".[convert]" # adds jax + orbax (for JAX checkpoint conversion)Mel-spectrogram extraction is pure PyTorch; torchaudio is only needed to
resample audio that is not already at 16 kHz.
from_pretrained reads config.json next to the weights, picks the matching
variant, builds the model on the meta device, and streams the checkpoint into it:
import torch
import gemma4_pt_claude as gemma4
model = gemma4.from_pretrained(
"/path/to/hf-model-dir",
dtype=torch.bfloat16,
device="cuda",
)It defaults to strict=True, so a variant mismatch fails loudly instead of
leaving randomly initialized weights in place. Pass text_only=True to skip the
vision/audio towers, or variant="gemma4_e2b" when there is no config.json.
To pick the variant yourself, construct on meta and stream:
with torch.device("meta"):
model = gemma4.gemma4_e2b(text_only=False)
gemma4.load_weights_streaming(
model,
"/path/to/hf-model-dir",
format="hf",
dtype=torch.bfloat16,
device="cuda",
)
model.eval()load_weights / load_weights_streaming default to strict=False and warn when
parameters are missing from the checkpoint — those keep their initial values, so
a warning here means the model does not match the weights.
Top-level factory functions behave differently depending on whether you construct them normally or under a meta-device context:
- normal construction: creates a fully initialized model
with torch.device("meta"): creates an unmaterialized model, intended for checkpoint loading
import gemma4_pt_claude as gemma4
model = gemma4.gemma4_e2b(text_only=True)
model.eval()For instruction-tuned checkpoints, prefer chat() for single-turn prompts:
import torch
import gemma4_pt_claude as gemma4
weights_dir = "/path/to/hf-model-dir"
tokenizer = gemma4.Gemma4Tokenizer(weights_dir)
model = gemma4.from_pretrained(weights_dir, dtype=torch.bfloat16, device="cuda", text_only=True)
response = gemma4.chat(
model,
tokenizer,
"Explain rotary position embeddings in plain English.",
max_new_tokens=192,
temperature=0.0,
)
print(response)Pass a callback to see tokens as they are produced; return True to stop early:
def on_token(step): # step is a [B] tensor of newly sampled token ids
print(tokenizer.decode([int(step[0])]), end="", flush=True)
gemma4.chat(model, tokenizer, "Write a haiku about KV caches.", callback=on_token)generate() accepts a batch of equal-length prompts. Each sequence stops
independently at its first stop token and is padded with pad_token_id
afterwards:
tokens = torch.stack([prompt_a, prompt_b]) # [2, L], same length
out = gemma4.generate(model, tokens, max_new_tokens=64, temperature=0.0)For lower-level control, build the chat prompt yourself and call generate():
prompt_ids = [tokenizer.BOS, tokenizer.START_OF_TURN]
prompt_ids += tokenizer.encode("user\nSummarize grouped-query attention.")
prompt_ids += [tokenizer.END_OF_TURN]
prompt_ids += tokenizer.encode("\n")
prompt_ids += [tokenizer.START_OF_TURN]
prompt_ids += tokenizer.encode("model\n")
tokens = torch.tensor([prompt_ids], device="cuda")
output = gemma4.generate(model, tokens, max_new_tokens=128, temperature=0.0)
gen_ids = output[0, len(prompt_ids):].tolist()
for i, tid in enumerate(gen_ids):
if tid in {tokenizer.EOS, tokenizer.END_OF_TURN}:
gen_ids = gen_ids[:i]
break
print(tokenizer.decode(gen_ids))The Composer class is the recommended multimodal entrypoint. It handles:
- image/audio preprocessing
- placeholder token injection
- Gemma 4 chat formatting
Prefer compose_chat(...) unless you already have a fully formatted prompt and
need the lower-level compose(...) path.
import torch
from gemma4_pt_claude import Composer, Gemma4Tokenizer, gemma4_e2b, load_weights_streaming, generate
weights_dir = "/path/to/hf-model-dir"
with torch.device("meta"):
model = gemma4_e2b()
load_weights_streaming(model, weights_dir, format="hf", dtype=torch.bfloat16, device="cuda")
model.eval()
tokenizer = Gemma4Tokenizer(weights_dir)
composer = Composer(tokenizer, model.cfg)from PIL import Image
img = Image.open("photo.jpg")
composed = composer.compose_chat("Describe this image in detail.", images=[img])
kwargs = composed.to_model_kwargs(device="cuda")
output = generate(
model,
kwargs.pop("tokens"),
max_new_tokens=256,
temperature=0.0,
**kwargs,
)
# Decode generated tokens
prompt_len = composed.input_ids.shape[1]
gen_ids = output[0, prompt_len:].tolist()
for i, tid in enumerate(gen_ids):
if tid in {tokenizer.EOS, tokenizer.END_OF_TURN}:
gen_ids = gen_ids[:i]
break
print(tokenizer.decode(gen_ids))The Composer automatically:
- Resizes and patchifies the image for the vision encoder
- Inserts
<|image|>markers with the correct number of soft-token placeholders - Builds the image mask for the model's multimodal embedding merge
Image blocks are emitted as <start_of_image> + soft tokens + <end_of_image>
with no surrounding newlines, matching the Gemma 4 reference processor. (Gemma 3
and 3n wrapped image blocks in \n\n; pass Composer(..., image_newline_wrap=True)
to reproduce that.)
Requires torchaudio for resampling (install with pip install -e ".[audio]").
import torchaudio
waveform, sample_rate = torchaudio.load("recording.wav")
waveform = waveform.mean(dim=0) # stereo to mono
composed = composer.compose_chat(
"Transcribe this audio clip exactly.",
audios=[waveform],
audio_sample_rates=sample_rate,
)
kwargs = composed.to_model_kwargs(device="cuda")
output = generate(
model,
kwargs.pop("tokens"),
max_new_tokens=256,
temperature=0.0,
**kwargs,
)
prompt_len = composed.input_ids.shape[1]
gen_ids = output[0, prompt_len:].tolist()
for i, tid in enumerate(gen_ids):
if tid in {tokenizer.EOS, tokenizer.END_OF_TURN}:
gen_ids = gen_ids[:i]
break
print(tokenizer.decode(gen_ids))The Composer handles mel spectrogram extraction, resampling to 16 kHz, and audio soft-token placeholder injection.
composed = composer.compose_chat(
"Describe what you see and hear.",
images=[img],
audios=[waveform],
audio_sample_rates=16000,
)If you need full manual control, you can preprocess images directly and build the multimodal prompt yourself:
from PIL import Image
import torch
import gemma4_pt_claude as gemma4
img = Image.open("photo.jpg")
data = gemma4.preprocess_images([img], model.cfg.vision)
n = data["num_soft_tokens_per_image"][0]
tok = gemma4.Gemma4Tokenizer
prompt_ids = [tok.BOS, tok.START_OF_TURN]
prompt_ids += tokenizer.encode("user\n")
prompt_ids += [tok.START_OF_IMAGE] + [tok.IMAGE_PLACEHOLDER] * n + [tok.END_OF_IMAGE]
prompt_ids += tokenizer.encode("\nDescribe this image in detail.")
prompt_ids += [tok.END_OF_TURN]
prompt_ids += tokenizer.encode("\n")
prompt_ids += [tok.START_OF_TURN]
prompt_ids += tokenizer.encode("model\n")
tokens = torch.tensor([prompt_ids], device="cuda")
image_mask = (tokens == tok.IMAGE_PLACEHOLDER)
output = gemma4.generate(
model, tokens,
max_new_tokens=256,
temperature=0.0,
pixel_values=data["pixel_values"].to("cuda", dtype=torch.bfloat16),
image_position_ids=data["image_position_ids"].to("cuda"),
image_mask=image_mask,
)output = gemma4.generate(
model, tokens,
max_new_tokens=256,
temperature=0.7, # 0 = greedy
top_k=50, # 0 = disabled
top_p=0.95, # 1.0 = disabled
)Practical note:
temperature=0.0is greedy and is the safest setting for comparisons/regression tests- very high temperatures with
top_k=0andtop_p=1.0will produce degenerate sampling quickly
generate() sizes the cache to prompt + max_new_tokens and raises if a write
would overflow it, rather than silently evicting tokens the model still needs.
Sliding-window layers only ever attend within their window, so they are allocated
max(sliding_window_size, prompt_len) slots and roll over. This is where most of
the KV memory goes at long context:
| Model | Cache @ 32k context | With window-sized local layers |
|---|---|---|
| 31B | 29.5 GB | 3.5 GB |
| 26B-A4B | 7.4 GB | 0.9 GB |
Building a cache yourself:
cache = gemma4.init_cache(
model.cfg, batch_size=1, cache_length=32768,
dtype=torch.bfloat16, device="cuda",
prefill_len=len(prompt_ids), # omit for full-length caches on every layer
)Global layers always keep the full context. The saving depends on the prompt being short relative to the total context, since a single-shot prefill has to hold all of its own keys at once.
Supports HuggingFace safetensors directories and native safetensors files.
For large HF checkpoints, prefer the streaming loader:
# HuggingFace model directory
with torch.device("meta"):
model = gemma4.gemma4_31b(text_only=False)
gemma4.load_weights_streaming(
model,
"/path/to/hf-model/",
format="hf",
dtype=torch.bfloat16,
device="cuda",
)
model.eval()The non-streaming loader remains available:
# HuggingFace model directory (remaps weights through a temporary CPU dict)
gemma4.load_weights(model, "/path/to/hf-model/", format="hf")
# Single safetensors file (native format)
gemma4.load_weights(model, "model.safetensors")Both loaders handle:
- HF key remapping
- gate/up projection merging
- MoE routing/expert remaps
- vision/audio tensor name conversion
- Use meta construction plus
load_weights_streaming(...)for large HF checkpoints. - Use normal construction only when you want a fresh randomly initialized model.
- Call
model.eval()after loading. - Use
compose_chat(...)for multimodal prompts unless you already have a fully templated prompt. - Use
text_only=Trueif you do not need vision/audio towers. - Keep
temperature=0.0for deterministic eval and debugging.
Convert Orbax (JAX) checkpoints to our safetensors format:
gemma4-convert --checkpoint gs://gemma-data/checkpoints/gemma4-e2b-it \
--variant e2b \
--output weights/gemma4-e2b.safetensorsTwo attention implementations, controlled by attn_impl in the text config:
"sdpa"(default) —F.scaled_dot_product_attention. Faster, less memory, benefits from FlashAttention/cuDNN when available."eager"— Manual einsum path. Required when usingattn_logits_soft_cap(falls back automatically). Useful for debugging.
Gemma4Model
├── text_decoder (TextDecoder)
│ ├── embedder (Embedder)
│ │ ├── token_embedding
│ │ └── per_layer_input (PLI) — small per-token-per-layer embeddings
│ ├── blocks[] (TransformerBlock)
│ │ ├── pre_attn_norm → Attention → post_attn_norm
│ │ ├── pre_ffw_norm → GatedMLP (or MoE + dense branch) → post_ffw_norm
│ │ ├── pli_mapping — gates + projects PLI back to embed_dim
│ │ └── skip_scale — learned scalar residual multiplier
│ └── final_norm
├── vision_encoder (VisionEncoder) — patch embed, 2-D RoPE, spatial pooling
│ └── embed_vision (VisionEmbedder) — RMSNorm + projection to text dim
├── audio_encoder (AudioEncoder) — conformer with SSCP frontend
│ └── embed_audio (AudioEmbedder) — RMSNorm + projection to text dim
└── composer (Composer) — tokenization + modality transforms + placeholder injection
Key architectural features:
- GQA — Grouped-query attention with configurable head counts per layer type
- Sliding window + global — Most layers use local sliding window; every Nth layer is global
- QK-norm — RMSNorm on queries and keys replaces 1/sqrt(d) scaling
- Per-layer input (PLI) — Lightweight per-token-per-layer embeddings (E2B, E4B)
- KV sharing — Later layers reuse KV from earlier layers, no redundant cache allocation (E2B, E4B)
- K=V — Key and value projections share weights for global layers (31B, 26B-A4B)
- MoE — 128 experts with top-8 routing + parallel dense branch (26B-A4B)
- Encoder-free multimodal — no vision/audio towers; raw patches and waveform frames projected straight into text space (12B)
- Skip scale — Learned scalar multiplied at the end of each block
- Vision encoder — ViT-style with factorised 2-D positional embeddings and bidirectional attention
- Audio encoder — Conformer stack with mel spectrogram frontend and relative position embeddings
src/gemma4_pt_claude/
├── config.py # All dataclass configs (TextConfig, VisionConfig, AudioConfig, etc.)
├── layers.py # RMSNorm, GatedMLP, RoPE, ClippedLinear
├── attention.py # GQA attention with SDPA/eager backends, sliding window, KV cache
├── moe.py # Router + experts + MoE layer
├── transformer.py # TransformerBlock, Embedder, PLI, TextDecoder
├── vision_encoder.py # Vision encoder (patch embed, 2-D RoPE, spatial pooling)
├── image_processing.py # Image preprocessing (resize, patchify, pad)
├── audio_encoder.py # Conformer audio encoder
├── audio_processing.py # Mel spectrogram extraction (matching JAX GemaxMelFilterbank)
├── model.py # Top-level Gemma4Model + multimodal embedding merge
├── generate.py # Autoregressive generation with KV cache
├── composer.py # Multimodal Composer (tokenize + transform + inject)
├── tokenizer.py # SentencePiece + HuggingFace tokenizer backends
├── load.py # Weight loading (HF safetensors + native)
├── encoder_free.py # Encoder-free vision/audio embedders (12B)
├── factory.py # gemma4_e2b/e4b/12b/31b/26b_a4b()
└── convert.py # JAX Orbax → safetensors checkpoint conversion
pytest tests/ # full suite (~265 tests)
pytest tests/ -k "vision" # filter by keyword
pytest tests/test_generate.py -v # single file, verbosetests/test_generate.py holds the equivalence properties worth protecting:
- a cached decode reproduces the uncached forward pass (including past the sliding window, with KV sharing, and with rolling caches)
- the
sdpaandeagerbackends agree - batched rows match generating each prompt on its own
- cache overflow raises
- bidirectional image spans survive the cached prefill path
Every module was verified against the JAX reference implementation with tight tolerances:
| Module | Max Abs Error |
|---|---|
| RMSNorm | 4.77e-07 |
| RoPE | 2.38e-07 |
| GatedMLP | 4.86e-09 |
| Attention (non-GQA) | 1.01e-07 |
| Attention (GQA) | 2.53e-07 |
| Attention (K=V) | 8.94e-08 |
| Embedder | 4.66e-10 |
| PLI | 4.17e-07 |
| TransformerBlock (dense) | 1.12e-05 |
| TransformerBlock (GQA+sliding) | 2.31e-05 |
End-to-end generation verified on E2B with both HuggingFace and Orbax weights (text, image captioning, and audio transcription all produce correct output).
- Text attention uses SDPA by default and falls back to the eager einsum path
when
attn_logits_soft_capis set. The two agree to ~6e-08. - The vision tower uses SDPA with
scale=1.0(QK-norm replaces1/sqrt(d)) and a large-finite-negative additive mask, so fully-masked padding rows stay finite. - Gemma 4 norms are multiplicative (
x_normed * weight, one-initialized), unlike Gemma 3'sx_normed * (1 + weight)around a zero-initialized scale. This matters for fresh initialization: a zero scale would make every block an exact identity with permanently zero gradients.
Apache 2.0 — see LICENSE.