Repository navigation
🚨 Fix float16 overflow in Gemma4 vision pooler - #46277
zucchini-nlp merged 3 commits into
Conversation
`Gemma4VisionPooler` scales hidden states by `sqrt(hidden_size)`. For the high-magnitude vision activations produced by the larger Gemma-4 checkpoints, this product exceeds the float16 maximum (65504) and saturates to `inf`, which then turns the downstream logits into `NaN`. Compute the pooler scaling and the following standardize in float32 and cast back to the working dtype once the magnitude is small again. This is a no-op in float32, keeps float16 finite, and computes the (cancellation-prone) standardize more accurately in low precision. The persistent `std_bias`/`std_scale` buffers are left untouched, so checkpoint round-trips are unchanged. Add a regression test that reproduces the float16 scaling overflow on a small config and checks that the output stays finite and matches the float32 result.
87c8065 to
7251d0b
Compare
|
cc @Cyrilvallez @zucchini-nlp — small float16 numerical fix for the Gemma4 vision pooler (modular). Would appreciate a review when you have a moment. Thanks! |
| # Scale in float32 and return float32: the sqrt(hidden_size) scaling can push the | ||
| # activations past the float16 range (max 65504), so the magnitude is kept in float32 | ||
| # until the caller standardizes it. | ||
| hidden_states = hidden_states.float() * self.root_hidden_size | ||
| return hidden_states, padding_positions |
There was a problem hiding this comment.
hm, could we instead cast back the hidden states to original_dtype before returning?
There was a problem hiding this comment.
Casting back to the input dtype inside the pooler would re-introduce the overflow: the * sqrt(hidden_size) product already exceeds the float16 range (> 65504) on the larger checkpoints, and it's the following standardize — which runs in Gemma4VisionModel.forward, after the pooler returns — that brings the magnitude back down. So a float16 cast here is exactly where it saturates to inf.
To keep the pooler returning the input dtype as you suggest, I think the cleanest option is to drop the scaling from the pooler entirely and fold the factor into the standardize, so it stays in the input dtype and the large intermediate is never formed.
1. Gemma4VisionPooler.forward — remove the scaling, return the pooled features in the input dtype:
if hidden_states.shape[1] != output_length:
hidden_states, padding_positions = self._avg_pool_by_positions(
hidden_states, pixel_position_ids, output_length
)
return hidden_states, padding_positions2. Gemma4VisionModel.forward — fold root_hidden_size into the standardize (replacing the current (hidden_states - self.std_bias) * self.std_scale):
# Strip padding tokens. pooler_mask is True = valid, False = padding.
hidden_states = hidden_states[pooler_mask]
if self.config.standardize:
# Fold the pooler's sqrt(hidden_size) scaling into the standardize. This is
# mathematically equal to `(hidden_states * root_hidden_size - std_bias) * std_scale`,
# but the largest intermediate is `hidden_states - std_bias / root_hidden_size`, so it
# never overflows float16 and needs no float32 upcast.
root_hidden_size = self.pooler.root_hidden_size
hidden_states = (hidden_states - self.std_bias / root_hidden_size) * self.std_scale * root_hidden_size
else:
hidden_states = hidden_states * self.pooler.root_hidden_size
return BaseModelOutputWithPast(last_hidden_state=hidden_states)(Edited in modular_gemma4.py and regenerated.) std_bias / std_scale are left untouched, so checkpoints are unaffected. Does that look better to you?
zucchini-nlp
left a comment
There was a problem hiding this comment.
Ah right, indeed! Lets add 🚨 in pr title since it is breaking if we change return dtype, even for an internal module
|
Done, added the 🚨. Though if the fold I proposed above works for you, the pooler keeps returning the input dtype (the scaling just moves into the standardize), so it wouldn't be a breaking change and we could drop the marker. Happy to go either way, just let me know which you prefer. |
|
Will be breaking in any case though, if we move the scaling outside the module 🥲 Fine for me to leave this way |
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
|
[For maintainers] Suggested jobs to run (before merge) run-slow: gemma4 |
* Fix float16 overflow in Gemma4 vision pooler `Gemma4VisionPooler` scales hidden states by `sqrt(hidden_size)`. For the high-magnitude vision activations produced by the larger Gemma-4 checkpoints, this product exceeds the float16 maximum (65504) and saturates to `inf`, which then turns the downstream logits into `NaN`. Compute the pooler scaling and the following standardize in float32 and cast back to the working dtype once the magnitude is small again. This is a no-op in float32, keeps float16 finite, and computes the (cancellation-prone) standardize more accurately in low precision. The persistent `std_bias`/`std_scale` buffers are left untouched, so checkpoint round-trips are unchanged. Add a regression test that reproduces the float16 scaling overflow on a small config and checks that the output stays finite and matches the float32 result. * no need for integration test --------- Co-authored-by: raushan <raushan@huggingface.co>
* Fix float16 overflow in Gemma4 vision pooler `Gemma4VisionPooler` scales hidden states by `sqrt(hidden_size)`. For the high-magnitude vision activations produced by the larger Gemma-4 checkpoints, this product exceeds the float16 maximum (65504) and saturates to `inf`, which then turns the downstream logits into `NaN`. Compute the pooler scaling and the following standardize in float32 and cast back to the working dtype once the magnitude is small again. This is a no-op in float32, keeps float16 finite, and computes the (cancellation-prone) standardize more accurately in low precision. The persistent `std_bias`/`std_scale` buffers are left untouched, so checkpoint round-trips are unchanged. Add a regression test that reproduces the float16 scaling overflow on a small config and checks that the output stays finite and matches the float32 result. * no need for integration test --------- Co-authored-by: raushan <raushan@huggingface.co>
…931) * Fix Gemma-4 vision pooler float16 overflow on transformers <= 5.9.x Gemma4VisionPooler.forward scales pooled activations by sqrt(hidden_size) in the input dtype on transformers 5.5.0 through 5.9.x (and the yanked 5.10.0). For the 26B-A4B / 31B vision tower (hidden 1152, no clipped linears) fp16 activations near 2300 scale past the fp16 max of 65504, producing inf then NaN loss through embed_vision. This engages under forced-float32 mode, which keeps the unquantized vision tower in fp16 with autocast off. Mirror the upstream >= 5.10.1 fix (huggingface/transformers#46277) as a temporary patch: run the pooler in fp32, let standardization promote to fp32, and cast last_hidden_state back to the pixel_values dtype in Gemma4VisionModel.forward. Source-pattern classification skips fixed releases entirely and leaves unrecognized future sources untouched; transformers without gemma4 no-op via ImportError. bf16 and fp32 inputs are bit-identical to upstream. * Address review: cast to encoder working dtype, tuple returns, repair path Cast last_hidden_state back to the patch embedder input_proj weight dtype (upstream's inputs_embeds.dtype) instead of the raw pixel dtype, gated to fp16 towers, so fp32 processor pixels into an fp16 tower no longer leak fp32 features into the fp16 embed_vision projection, and bf16 / fp32 setups stay bit-identical even with mixed input dtypes. Handle tuple returns from return_dict=False. Install the vision wrapper before the pooler wrapper so the pooler marker is the commit point, and re-install a missing vision wrapper next to a marked pooler instead of reporting already. Classify the buggy pattern before the fixed one. Strengthen the standardize=False test to exact upstream semantics and add coverage for mixed dtypes, tuple returns, repair, and unavailable source. * Add license header to the vision pooler test module * Address Codex review: capture real embeddings dtype, sync hidden_states Capture the actual patch embedder output dtype with a transient forward hook instead of assuming the input_proj weight dtype: under bf16 autocast an fp16-weight Linear emits bf16 (autocast recasts non-fp32 args), so the weight-based target would downcast a previously safe bf16 path and could saturate standardize=False outputs. The weight dtype remains only as a fallback when the hook sees nothing. Also keep captured outputs consistent: when output_hidden_states=True ties hidden_states[-1] to the pre-cast final state, cast that entry too, and swap the pre-cast tensor inside nested tuples on the return_dict=False path. * Tighten comments in the Gemma-4 vision pooler fp16 fix * Tighten vision pooler fp16 fix banner
* Fix float16 overflow in Gemma4 vision pooler `Gemma4VisionPooler` scales hidden states by `sqrt(hidden_size)`. For the high-magnitude vision activations produced by the larger Gemma-4 checkpoints, this product exceeds the float16 maximum (65504) and saturates to `inf`, which then turns the downstream logits into `NaN`. Compute the pooler scaling and the following standardize in float32 and cast back to the working dtype once the magnitude is small again. This is a no-op in float32, keeps float16 finite, and computes the (cancellation-prone) standardize more accurately in low precision. The persistent `std_bias`/`std_scale` buffers are left untouched, so checkpoint round-trips are unchanged. Add a regression test that reproduces the float16 scaling overflow on a small config and checks that the output stays finite and matches the float32 result. * no need for integration test --------- Co-authored-by: raushan <raushan@huggingface.co>
…#2146) Fixes unslothai/unsloth#15473 Gemma 4 26B-A4B (and 31B) on MLX answer an image prompt with `<unused6226>` over and over whenever the model runs in float16, which is what `FastMLXModel.from_pretrained(dtype = None)` (and so Unsloth Studio / Desktop) picks on Apple M1 / M2. Text-only prompts are fine, and bf16 Macs (M3 and newer) are fine. ## Root cause mlx-vlm's `gemma4.vision.VisionPooler` multiplies the pooled patches by `sqrt(hidden_size)` (33.9 for the 1152-wide tower) in the working dtype. The 26B / 31B towers have no clipped linears, and their pooled patches reach about 2,500, so the product passes float16's 65,504 and becomes `inf`. `embed_vision`'s RMSNorm then turns each such soft token into all NaN, attention spreads it, every logit is NaN, and argmax lands on the last vocabulary id, `<unused6226>`. E2B / E4B have clipped linears and a smaller tower, so they stay in range. This is the same overflow #931 fixed for the torch path (`temporary_patches/gemma4.py`), and transformers fixed upstream in 5.10.1 (huggingface/transformers#46277): `Gemma4VisionPooler` scales in float32 and the caller casts back after standardizing. mlx-vlm 0.7.9 (Studio's pin), 0.7.10 and current main still scale in the working dtype, so the MLX path never got it. ## Change `_fix_gemma4_vision_pooler_fp32`, added to `_VLM_MODEL_FIXUPS` next to the existing Gemma 3 fp32 fixups, mirroring #931: - the pooler upcasts float16 input to float32 before pooling and scaling; - `VisionModel.__call__` casts the standardized output back to float16, so `embed_vision` and the text model see the dtype they did before. bf16 and float32 inputs are passed through untouched, so those outputs are bit-identical to stock mlx-vlm (tested). The patch is class-level and idempotent. ## Evidence Real `mlx-community/gemma-4-26b-a4b-it-4bit` vision tower + `embed_vision` (mlx 0.32.4, mlx-vlm 0.7.9, CPU backend), the issue's image (white canvas, red bar, blue disc), through mlx-vlm's own `VisionModel.__call__`: | dtype | tower output inf | soft-token NaN | cosine vs float32 | |---|---|---|---| | float32 | 0 | 0 | 1 | | float16, stock | 5 | 14080 (5 of 64 tokens x 2816) | - | | float16, this PR | 0 | 0 | 0.99993 | Per-layer probe: all 27 encoder layers stay finite in float16 (peak 2,502); the overflow is only at the pooler scale. Cross-check with the original `google/gemma-4-26B-A4B-it` weights in HF transformers on an A100 80GB, greedy decode, both an image prompt and a text-only prompt: bf16, fp16, and fp16 text with bf16 vision all describe the image correctly ("A square image with a white background. On the left side, there is a red rectangle...") and answer text normally. HF's text stack peaks at about 142 in fp16, so the text side does not need a change. `tests/test_mlx_gemma4_vision_fp16.py` (native MLX only, skipped under the simulated harness): fp16 soft tokens past the fp16 range come out finite and close to float32; the setup overflows without the fix; bf16 output is bit-identical to stock; the patch is idempotent. With the upcast line removed, `test_fp16_soft_tokens_finite` fails.
What does this PR do?
Gemma4VisionModelscales the pooled vision features bysqrt(hidden_size)before the standardize step. For the high-magnitude vision activations produced by the larger Gemma-4 checkpoints, this product exceeds the float16 maximum (65504) and saturates toinf, which then propagates asNaNthrough the language-model logits, so the model is unusable in float16.The large value is only a transient — the immediately following
(hidden_states - std_bias) * std_scalestandardize brings the magnitude back down — but in float16 it overflows before the standardize can recover it.Fix
Gemma4VisionPoolerapplies itssqrt(hidden_size)scaling in float32 and returns float32;Gemma4VisionModel.forwardthen standardizes in float32 and casts back to the working dtype once the magnitude is small again:std_biassubtraction is a cancellation of large values (std_biasis on the order of 1e4–1e5), so the result is not bit-identical to before, but the difference is within low-precision noise.The persistent
std_bias/std_scalebuffers are left untouched, so existing checkpoints round-trip unchanged.Reproduction
google/gemma-4-31b-itin float16 returnsNaNlogits for any image input. Evaluating MMBench (dev EN, 200 samples):mainmainOn
main, every float16 sample overflows and yieldsNaNlogits (the 42% is the degenerate all-NaNfallback, not real accuracy). With the fix, float16 matches bfloat16, and bfloat16 is unchanged.Tests
Added
Gemma4VisionPoolerFloat16Test, which reproduces the float16 scaling overflow on a small config (forcing the high-activation regime the large checkpoints reach) and asserts the output stays finite and matches the float32 result. It fails onmainand passes with this PR.Before submitting
modeling_gemma4.pywas regenerated frommodular_gemma4.py.