Gemma-4 E2B/E4B: enable image (multimodal) parity — vision clipped-linears + padded-patch masking + PLE fix - #4790
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for Gemma-4 E2B/E4B multimodal models by implementing opt-in, checkpoint-resident, non-trainable activation clip bounds (clipped-linears) for the vision encoder. The changes include adding configuration options, parameter mapping, and custom projection overrides in Gemma4Attention and Gemma4ClippedMlpBlock to apply the clipping. The review feedback highlights critical integration gaps: the validation function validate_clip_bounds and the optimizer freeze mask clip_optimizer_freeze_mask are defined but never actually called or integrated into the model initialization or optimizer setup. Additionally, the reviewer recommends raising an error if fused_qkv is enabled, as it would currently bypass the attention projection clipping.
| def validate_clip_bounds(cb, where=""): | ||
| """Hard-fail: every bound finite + scalar. Raises ValueError otherwise. No-op if ``cb`` is None.""" | ||
| if cb is None: | ||
| return | ||
| for nm in ("input_min", "input_max", "output_min", "output_max"): | ||
| v = getattr(cb, nm).value | ||
| if getattr(v, "shape", ()) not in ((), (1,)): | ||
| raise ValueError(f"Gemma4 vision clip bound '{nm}'{(' @ '+where) if where else ''} has non-scalar " | ||
| f"shape {v.shape}; expected scalar.") | ||
| fv = float(jnp.reshape(v, (-1,))[0]) | ||
| if not bool(jnp.isfinite(jnp.asarray(fv))): | ||
| raise ValueError(f"Gemma4 vision clip bound '{nm}'{(' @ '+where) if where else ''} = {fv} is non-finite " | ||
| f"(missing/NaN/Inf). use_clipped_linears_for_vit=True declares a FINITE clipped model; " | ||
| f"refusing to fall back to an identity clamp.") |
There was a problem hiding this comment.
The validate_clip_bounds function is defined here, and corresponding validate_clip_bounds methods are implemented in Gemma4Attention and Gemma4ClippedMlpBlock. However, these validation methods are never actually called anywhere in the codebase (e.g., during model initialization, warmup, or after checkpoint loading). This means the NaN sentinel check is completely bypassed, and any missing or NaN bounds in the checkpoint will silently propagate into jnp.clip, turning the entire activation tensor into NaNs.
Please ensure that validate_clip_bounds is called on the model layers after checkpoint loading or during model initialization (outside of JIT-compiled functions). Additionally, we can simplify the extraction of the scalar value and the finiteness check using float(v) and math.isfinite.
| def validate_clip_bounds(cb, where=""): | |
| """Hard-fail: every bound finite + scalar. Raises ValueError otherwise. No-op if ``cb`` is None.""" | |
| if cb is None: | |
| return | |
| for nm in ("input_min", "input_max", "output_min", "output_max"): | |
| v = getattr(cb, nm).value | |
| if getattr(v, "shape", ()) not in ((), (1,)): | |
| raise ValueError(f"Gemma4 vision clip bound '{nm}'{(' @ '+where) if where else ''} has non-scalar " | |
| f"shape {v.shape}; expected scalar.") | |
| fv = float(jnp.reshape(v, (-1,))[0]) | |
| if not bool(jnp.isfinite(jnp.asarray(fv))): | |
| raise ValueError(f"Gemma4 vision clip bound '{nm}'{(' @ '+where) if where else ''} = {fv} is non-finite " | |
| f"(missing/NaN/Inf). use_clipped_linears_for_vit=True declares a FINITE clipped model; " | |
| f"refusing to fall back to an identity clamp.") | |
| def validate_clip_bounds(cb, where=""): | |
| """Hard-fail: every bound finite + scalar. Raises ValueError otherwise. No-op if ``cb`` is None.""" | |
| if cb is None: | |
| return | |
| for nm in ("input_min", "input_max", "output_min", "output_max"): | |
| v = getattr(cb, nm).value | |
| if getattr(v, "shape", ()) not in ((), (1,)): | |
| raise ValueError(f"Gemma4 vision clip bound '{nm}'{(' @ '+where) if where else ''} has non-scalar " | |
| f"shape {v.shape}; expected scalar.") | |
| fv = float(v) | |
| import math | |
| if not math.isfinite(fv): | |
| raise ValueError(f"Gemma4 vision clip bound '{nm}'{(' @ '+where) if where else ''} = {fv} is non-finite " | |
| f"(missing/NaN/Inf). use_clipped_linears_for_vit=True declares a FINITE clipped model; " | |
| f"refusing to fall back to an identity clamp.") |
| def clip_optimizer_freeze_mask(params_tree): | ||
| """Bool pytree (same structure as ``params_tree``): True for TRAINABLE leaves, | ||
| False for the immutable clip bounds. Feed to ``optax.masked``/``multi_transform`` | ||
| so the bounds get ``set_to_zero()`` updates. Path-based, so it survives the | ||
| nnx->linen->orbax round-trip regardless of leaf type erasure.""" | ||
| flat = jax.tree_util.tree_flatten_with_path(params_tree)[0] | ||
| leaves_mask = [not _is_clip_bound_path(path) for path, _ in flat] | ||
| treedef = jax.tree_util.tree_structure(params_tree) | ||
| return jax.tree_util.tree_unflatten(treedef, leaves_mask) |
There was a problem hiding this comment.
The clip_optimizer_freeze_mask function is defined to exclude the immutable clip bounds from optimizer updates and weight decay. However, this function is never imported or called in the optimizer setup (e.g., in train.py or optimizers.py). As a result, if freeze_vision_encoder_params is set to False (e.g., during full fine-tuning or pre-training), these supposedly non-trainable clip bounds will be updated by the optimizer, which is incorrect.
Please integrate clip_optimizer_freeze_mask into the optimizer creation flow to ensure the clip bounds are correctly masked out and frozen.
| def enable_vision_clip_bounds(self): | ||
| """Attach the four checkpoint-resident clip-bound scalars for each of q/k/v/o. | ||
|
|
||
| Called once by ``Gemma4EncoderBlock`` after construction when | ||
| ``config.use_clipped_linears_for_vit`` is set. Idempotent. | ||
| """ | ||
| if getattr(self, "_use_clipped_linears", False): | ||
| return | ||
| self._use_clipped_linears = True | ||
| self.q_clip = _make_clip_state() | ||
| self.k_clip = _make_clip_state() | ||
| self.v_clip = _make_clip_state() | ||
| self.o_clip = _make_clip_state() |
There was a problem hiding this comment.
If fused_qkv is enabled in the configuration, the query, key, and value projections are fused into a single qkv_proj matrix multiplication. In this case, Gemma4Attention does not override qkv_projection to apply the per-projection clip bounds, meaning the clipping will be silently bypassed for Q, K, and V. Just like Gemma4ClippedMlpBlock raises an error when fused_mlp is enabled, Gemma4Attention should raise an error if fused_qkv is enabled when clipped linears are used.
| def enable_vision_clip_bounds(self): | |
| """Attach the four checkpoint-resident clip-bound scalars for each of q/k/v/o. | |
| Called once by ``Gemma4EncoderBlock`` after construction when | |
| ``config.use_clipped_linears_for_vit`` is set. Idempotent. | |
| """ | |
| if getattr(self, "_use_clipped_linears", False): | |
| return | |
| self._use_clipped_linears = True | |
| self.q_clip = _make_clip_state() | |
| self.k_clip = _make_clip_state() | |
| self.v_clip = _make_clip_state() | |
| self.o_clip = _make_clip_state() | |
| def enable_vision_clip_bounds(self): | |
| """Attach the four checkpoint-resident clip-bound scalars for each of q/k/v/o. | |
| Called once by ``Gemma4EncoderBlock`` after construction when | |
| ``config.use_clipped_linears_for_vit`` is set. Idempotent. | |
| """ | |
| if getattr(self, "_use_clipped_linears", False): | |
| return | |
| if getattr(self.config, "fused_qkv", False): | |
| raise ValueError("Gemma4Attention with clipped linears requires fused_qkv=False.") | |
| self._use_clipped_linears = True | |
| self.q_clip = _make_clip_state() | |
| self.k_clip = _make_clip_state() | |
| self.v_clip = _make_clip_state() | |
| self.o_clip = _make_clip_state() |
…erequisite)
The Gemma-4 E2B/E4B reference checkpoint ships per-projection activation clip
bounds for the vision tower: each of the 7 vision projections
(self_attn.{q,k,v,o}_proj and mlp.{gate,up,down}_proj) in every encoder block
carries a scalar {input,output}_{min,max} (16 blocks x 7 x 4 = 448 bounds), and
the reference forward clamps each projection's input and output by those bounds.
MaxText does not model them today, so the values are silently dropped on
conversion. They are one of the pieces required before E2B/E4B image inputs can
match the reference.
This adds the clip bounds as an opt-in, checkpoint-resident, non-trainable
feature gated on use_clipped_linears_for_vit (exact no-op when False):
- gemma4_vision.py: clip-bound helpers (_clip_in/_clip_out, _ClipBounds,
NaN-sentinel validate_clip_bounds, path-based clip_optimizer_freeze_mask).
Gemma4Attention overrides its q/k/v/o projection methods to clamp-in ->
DenseGeneral -> clamp-out; Gemma4ClippedMlpBlock does the same for
gate/up/down. The shared attentions.Attention / linears.MlpBlock are untouched,
and the underlying DenseGeneral weights and checkpoint key paths are unchanged
(the bounds are separate scalar leaves).
- param_mapping.py: maps the 448 clip-bound scalars from the HF checkpoint when
the flag is set.
- types.py / base.yml: adds the use_clipped_linears_for_vit flag (default False).
The bounds are plain nnx.Param leaves so they round-trip through the
nnx->linen->orbax checkpoint path and map to the canonical params collection;
clip_optimizer_freeze_mask keeps them out of optimizer updates and weight decay
via a leaf-path mask. A NaN sentinel + validate_clip_bounds hard-fails on a
missing/non-finite bound rather than silently degrading to an identity clamp.
Scope / status (deliberately conservative):
- This does NOT unblock E2B/E4B multimodal. The existing validator that gates
E2B/E4B image inputs is left in place, because clip-bounds are necessary but
NOT sufficient for image parity on their own.
- Verified: the 448 bounds convert and load with real finite values; the clamp
math is bit-exact vs jnp.clip, dtype-preserving, and an exact no-op when
disabled (checked at the helper level and on a real Gemma4EncoderBlock forward,
where wide bounds reproduce the disabled-path output exactly and tight bounds
bound the projected activations).
- End-to-end teacher-forced image parity is NOT yet achieved: with clip-bounds
loaded, the text path is exact but the image span still diverges from the HF
reference, because the current Gemma-4 vision forward is missing other pieces
of the reference contract (e.g. pad-patch attention masking and external image
position threading). Those are out of scope for this change and tracked
separately; this PR lands the clip-bounds building block on its own.
7428140 to
dc7c56b
Compare
|
Converted to draft and narrowed the scope after end-to-end validation. What changed vs the initial version of this PR:
E2E findings (CPU, teacher-forced, vs HF reference logits):
This clip-bounds change is off by default and safe to land on its own; keeping it as draft until the remaining vision-forward pieces land, at which point E2B/E4B image parity can be demonstrated end-to-end. |
…-row substitution) Builds on the vision clipped-linears to make Gemma-4 E2B/E4B image inputs match the HF reference end to end. The clipped-linears alone are necessary but not sufficient; the reference contract also needs the vision padded-patch handling and a decoder-side per-layer embedding (PLE) fix. Vision (models/gemma4_vision.py): - Gemma4EncoderBlock threads decoder_segment_ids into attention (valid=1 / pad=2) so the phantom padded patches are masked out of vision self-attention. - Gemma4VisionEncoderLayer gains a padded-patch path (image_position_ids != None): consume pre-patchified patches + real per-patch positions (-1 = pad), build the segment ids, pool by the real positions, and return the pooled-token validity mask. image_position_ids is None -> byte-identical legacy path. Threading (layers/encoders.py, models/models.py): - VisionEncoder / the model forward thread encoder_image_position_ids to the Gemma-4 vision encoder and route the returned validity mask into MultimodalInput.image_masks, so exactly the valid pooled tokens land in the image placeholders (merge_mm_embeddings.token_masks). Decoder (layers/nnx_decoders.py): - ple_pad_substitute_image_rows: Gemma-4 E2B/E4B build the per-layer inputs from llm_input_ids with image placeholder tokens mapped to pad_token_id (HF modeling_gemma4), rather than feeding the placeholder id into the PLE path. Without this the per-layer embeddings at the image positions diverge, corrupting the image-span and post-image logits. - use_bidirectional_image_attn: E2B/E4B image spans are causal; suppress the bidirectional attention carve-out unless explicitly enabled (bidirectional-image models set it True). Config (configs/types.py, configs/base.yml): - Adds use_bidirectional_image_attn, ple_pad_substitute_image_rows, ple_pad_mode, image_placeholder_token_id, ple_pad_token_id (defaults preserve behavior for other models). - Allows E2B/E4B multimodal when use_clipped_linears_for_vit is set. Validation (CPU, teacher-forced 340-token forward vs HF reference logits, converted E2B checkpoint): with the full fix and clip-bounds enabled, pre_image max_KL 2.7e-5, argmax 1.0 image_span max_KL 9.0e-5, argmax 1.0 post_image max_KL 4.3e-5, argmax 1.0 (frozen gate: <= 1.26e-3 and argmax >= 0.995) matching the reference. With clip-bounds disabled the image span diverges, confirming the clip-bounds are required. Defaults keep all changes off for non-Gemma-4 models.
So enabling image inputs only requires use_multimodal=true + use_clipped_linears_for_vit=true; the E2B/E4B model configs supply the decoder image-contract flags (causal image spans, PLE image-row pad substitution, placeholder/pad token ids). Also drops the stale 'multimodal not yet supported' comment.
|
Update — this PR now closes E2B/E4B image parity end-to-end. It initially landed only the vision clipped-linears (marked draft, since clip-bounds alone are necessary but not sufficient). After root-causing the remaining image-span divergence, the missing pieces were:
With all three plus the clip-bounds, the teacher-forced forward now matches the HF reference: post-image |
What
Enables Gemma-4 E2B/E4B image (multimodal) parity in MaxText. Previously E2B/E4B multimodal was gated off — the validator noted it was "pending clipped-linears in the vision encoder" — and produced diverging image logits. This PR lands the full set of pieces the HF reference contract needs, validated end-to-end.
To enable image inputs for these models you now set
use_multimodal=trueanduse_clipped_linears_for_vit=true; the E2B/E4B model configs supply the rest of the image contract. Everything is off by default, so other models and text-only runs are unchanged.Changes
1. Vision clipped-linears (
models/gemma4_vision.py,checkpoint_conversion/utils/param_mapping.py,configs/*)The Gemma-4 reference checkpoint ships per-projection activation clip bounds (7 vision projections ×
{input,output}_{min,max}× 16 blocks = 448 bounds). Added as opt-in, checkpoint-resident, non-trainable scalars gated onuse_clipped_linears_for_vit(exact no-op when off);Gemma4Attentionand aGemma4ClippedMlpBlockapply clamp-in → projection → clamp-out. NaN-sentinel validation; path-based optimizer-freeze mask. The converter maps the 448 bounds.2. Image-parity path (
models/gemma4_vision.py,layers/encoders.py,models/models.py,layers/nnx_decoders.py)Clip-bounds alone are necessary but not sufficient; this adds the rest of the reference contract:
Gemma4EncoderBlockthreadsdecoder_segment_ids(valid=1/pad=2) so phantom padded patches are masked out of vision self-attention;Gemma4VisionEncoderLayergains a padded-patch path (pre-patchified patches + real per-patch positions with-1sentinels), pools by real positions, and returns the pooled-token validity mask.encoder_image_position_idsflows through the model forward → vision encoder; the returned validity mask is routed intoMultimodalInput.image_masksso exactly the valid pooled tokens land in the image placeholders.pad_token_id(matching HFmodeling_gemma4), instead of feeding the placeholder id into the PLE path. Without this the per-layer embeddings at image positions diverge, corrupting the image-span/post-image logits.3. Config (
configs/types.py,configs/base.yml,configs/models/gemma4-e2b.yml,configs/models/gemma4-e4b.yml)Adds
use_clipped_linears_for_vit,use_bidirectional_image_attn,ple_pad_substitute_image_rows,ple_pad_mode,image_placeholder_token_id,ple_pad_token_id(defaults preserve behavior for other models); the E2B/E4B model configs set the image-contract defaults; the validator allows E2B/E4B multimodal whenuse_clipped_linears_for_vitis set.Validation (E2E, honest)
CPU, teacher-forced 340-token forward on a converted E2B checkpoint vs the HF reference logits. Frozen gate: post-image
max_KL ≤ 1.26e-3andargmax ≥ 0.995.max_KL 4.3e-5,argmax 1.0), matching an independently-measured reference baseline (~4.2e-5) for the same fixture.max_KL 1.195), confirming the clip-bounds are required.use_multimodal=true+use_clipped_linears_for_vit=true, model config supplies the rest) — same result.Additional unit/component checks: clip math bit-exact vs
jnp.clip, dtype-preserving, exact no-op when disabled; NaN/Inf bounds hard-fail; the vision padded-patch path yields exactly the expected valid pooled-token count.