Skip to content

🚨 Fix float16 overflow in Gemma4 vision pooler - #46277

Merged
zucchini-nlp merged 3 commits into
huggingface:mainfrom
Bluear7878:fix-gemma4-vision-pooler-fp16-overflow
Jun 1, 2026
Merged

zucchini-nlp merged 3 commits into
huggingface:mainfrom
Bluear7878:fix-gemma4-vision-pooler-fp16-overflow

Conversation

@Bluear7878

@Bluear7878 Bluear7878 commented May 29, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Gemma4VisionModel scales the pooled vision features by sqrt(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 to inf, which then propagates as NaN through 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_scale standardize brings the magnitude back down — but in float16 it overflows before the standardize can recover it.

Fix

Gemma4VisionPooler applies its sqrt(hidden_size) scaling in float32 and returns float32; Gemma4VisionModel.forward then standardizes in float32 and casts back to the working dtype once the magnitude is small again:

  • float32: unchanged (the upcast is a no-op).
  • float16: no longer overflows.
  • bfloat16: computed slightly more accurately. The std_bias subtraction is a cancellation of large values (std_bias is 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_scale buffers are left untouched, so existing checkpoints round-trip unchanged.

Reproduction

google/gemma-4-31b-it in float16 returns NaN logits for any image input. Evaluating MMBench (dev EN, 200 samples):

dtype branch accuracy overflow NaN logits
float16 main 42.0% 200/200 200/200
float16 this PR 91.0% 0/200 0/200
bfloat16 main 91.5% 0/200 0/200
bfloat16 this PR 91.5% 0/200 0/200

On main, every float16 sample overflows and yields NaN logits (the 42% is the degenerate all-NaN fallback, not real accuracy). With the fix, float16 matches bfloat16, and bfloat16 is unchanged.

Note: standardize=False in float16 is inherently unsupported (the pooled output is intentionally large); all released Gemma-4 checkpoints use standardize=True.

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 on main and passes with this PR.

Before submitting

  • This PR fixes a bug (float16 inference of large Gemma-4 vision models).
  • Did you write any new necessary tests? Yes.
  • modeling_gemma4.py was regenerated from modular_gemma4.py.

`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.
@Bluear7878
Bluear7878 force-pushed the fix-gemma4-vision-pooler-fp16-overflow branch from 87c8065 to 7251d0b Compare May 29, 2026 04:46
@Bluear7878

Copy link
Copy Markdown
Contributor Author

cc @Cyrilvallez @zucchini-nlp — small float16 numerical fix for the Gemma4 vision pooler (modular). Would appreciate a review when you have a moment. Thanks!

Comment on lines +652 to 656
# 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, could we instead cast back the hidden states to original_dtype before returning?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_positions

2. 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 zucchini-nlp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, indeed! Lets add 🚨 in pr title since it is breaking if we change return dtype, even for an internal module

@Bluear7878 Bluear7878 changed the title Fix float16 overflow in Gemma4 vision pooler 🚨 Fix float16 overflow in Gemma4 vision pooler May 29, 2026
@Bluear7878

Copy link
Copy Markdown
Contributor Author

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.

@zucchini-nlp

Copy link
Copy Markdown
Member

Will be breaking in any case though, if we move the scaling outside the module 🥲

Fine for me to leave this way

@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

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.

@zucchini-nlp
zucchini-nlp enabled auto-merge June 1, 2026 08:31
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: gemma4

@zucchini-nlp
zucchini-nlp added this pull request to the merge queue Jun 1, 2026
Merged via the queue into huggingface:main with commit 834c046 Jun 1, 2026
22 checks passed
kashif pushed a commit to kashif/transformers that referenced this pull request Jun 1, 2026
* 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>
khushali9 pushed a commit to khushali9/transformers that referenced this pull request Jun 8, 2026
* 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>
danielhanchen added a commit to unslothai/unsloth-zoo that referenced this pull request Jul 22, 2026
…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
stevhliu pushed a commit to stevhliu/transformers that referenced this pull request Jul 30, 2026
* 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>
danielhanchen added a commit to unslothai/unsloth-zoo that referenced this pull request Oct 28, 2026
…#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants