Skip to content

Add vision and audio support for Gemma 3n - #474

Merged
justinchuby merged 24 commits into
onnxruntime:mainfrom
NishantN2005:fix/gemma3n-export
Aug 13, 2026
Merged

Add vision and audio support for Gemma 3n#474
justinchuby merged 24 commits into
onnxruntime:mainfrom
NishantN2005:fix/gemma3n-export

Conversation

@NishantN2005

Copy link
Copy Markdown
Contributor

What this does

Makes google/gemma-3n-E4B-it export as a real 4-model multimodal package
(decoder + vision_encoder + audio_encoder + embedding) instead of a
text-only decoder.

Before this, both gemma3n and gemma3n_text mapped to
Gemma3nCausalLMModel, so Gemma3nCausalLMModel.preprocess_weights deleted
every vision_tower., audio_tower., and multi_modal_projector. key. The
export succeeded and produced a package that could not accept images or audio at
all. gemma3n now maps to a new Gemma3nMultiModalModel with task="gemma3n",
matching how gemma3 / gemma3_text are already split.

Two pre-existing text-path bugs fixed first

These affect the already-shipping text export and are independent of the
modality work, so they land as their own commits at the base of the branch. Both
silently produced wrong numbers rather than crashing.

  • KV layer sharing was not implemented (2f01334). E4B sets
    num_kv_shared_layers=15, so layers >= 20 reuse the KV of the last non-shared
    layer of the same type (sliding -> layer 18, full -> layer 19). Attention
    always recomputed k_proj/v_proj. The checkpoint does ship k_proj for all
    35 layers, so nothing failed to load -- the graph just computed KV that HF
    never uses, and the exported cache had 35 entries where HF effectively has 20.
  • Activation sparsity (_gaussian_topk) was missing (08a14a0). For E4B
    layers 0-9 activation_sparsity_pattern is 0.95. HF computes
    cutoff = mean + std * icdf(0.95) over the last dim and applies
    relu(gate_proj - cutoff) before the activation. icdf(0.95) is a
    compile-time constant, so no ONNX erf/ppf is needed.

Both were previously invisible because tests/synthetic_parity_test.py pinned
num_kv_shared_layers: 0 for gemma3n and never set a sparsity pattern. Those
escape hatches are removed here, so the existing HF-vs-mobius parity harness now
covers both.

Architecture notes

Three places where gemma3n does not match the obvious existing component, and
reusing it would have been wrong:

  • The vision tower is MobileNet-V5-300m, not SigLIP. HF routes
    gemma3n_vision to TimmWrapperModel, and timm is not a dependency. New
    components/_mobilenetv5.py implements the stem, EdgeResidual / UIB /
    UIB+MQA blocks, and the Multi-Scale Fusion Adapter, driven by a hard-coded
    per-stage spec table -- there is no config source for the block layout.
  • The 116 bn.weight tensors are timm RmsNorm2d, not BatchNorm. The
    checkpoint ships zero bn.bias / running_mean / running_var /
    num_batches_tracked. Using the existing components.BatchNorm2d would have
    demanded four initializers that do not exist.
  • The audio tower reuses _gemma4_audio.py except for the norm. gemma3n's
    SSCP conv blocks use Gemma3nAudioCumulativeGroupNorm -- group norm computed
    cumulatively over the time axis -- where gemma4 uses plain LayerNormNoBias.
    That needs CumSum over T. gemma3n also adds an explicit
    relative_position_embedding.pos_proj that gemma4 lacks.

components/_rms_norm.py gains a shared scale-free RMSNorm, promoted out of
models/gemma4.py rather than duplicated.

NishantN2005 and others added 18 commits August 10, 2026 23:34
Gemma 3n expresses intermediate_size as a per-layer list ([8192]*30 for
E2B, [16384]*35 for E4B) rather than a scalar. from_transformers copied
it through raw, so the list reached nn.Parameter/ir.Shape as a weight
dimension and raised TypeError during build.

Wrap the intermediate_size extraction in _as_int (already used for
hidden_size directly above), which collapses a uniform list to its first
element and leaves scalars untouched. Every shipped Gemma 3n checkpoint
keeps the list uniform, so the first element is the correct MLP width.

Add regression tests for both the scalar pass-through and the list case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemma 3n's trailing decoder layers borrow K,V from an earlier layer
instead of projecting their own. HF (modeling_gemma3n.py) has layers at
or after ``num_hidden_layers - num_kv_shared_layers`` reuse the K,V of
the last preceding layer of the *same* attention type, so sliding and
full-attention layers borrow from different sources. For E4B (35 layers,
15 shared) that means:

  layers 20-23,25-28,30-33 (sliding) -> reuse layer 18's K,V
  layers 24,29,34          (full)    -> reuse layer 19's K,V

mobius computed k_proj/v_proj unconditionally on every layer. The
checkpoint does ship k_proj/v_proj/k_norm for all 35 layers, so nothing
failed to load — the export silently computed KV that HF never uses and
emitted a 35-entry KV cache where HF effectively has 20.

Mirrors the existing Gemma4TextAttention implementation:

- ``Gemma3nConfig.num_kv_shared_layers``, populated from the HF config.
- ``Gemma3nAttention`` computes ``is_kv_shared_layer`` /
  ``kv_shared_layer_index`` / ``provides_shared_kv`` and, for shared
  layers, drops the k_proj/v_proj/k_norm submodules the parent built so
  they emit no initializers. Source layers publish their post-attention
  K,V into a ``shared_kv_states`` dict that shared layers read from,
  converting BNSH back to 3D. The opset-24 ``Attention`` op's present
  outputs have no ORT shape inference, so the known BNSH shape is pinned
  to keep the downstream o_proj MatMul inferable.
- ``Gemma3nTextModel`` emits present entries only for cache-owning
  layers and expands the shorter ``past_key_values`` list back to one
  slot per layer; ``kv_cache_layer_count()`` reports the real count, and
  ``CausalLMTask`` consults that hook when sizing the cache inputs
  (mirroring the existing ``static_kv_cache_specs`` precedent).
- ``preprocess_weights`` drops the shared layers' K/V tensors, which HF
  does not construct either.

Sharing was previously untestable: synthetic_parity_test.py pinned
``num_kv_shared_layers: 0`` for both gemma3n entries. The mixed-
layer_types entries keep an explicit 0 (with TINY_LAYERS=2 there is no
same-type source layer to borrow from), and the all-full-attention
gemma3n_text entry now sets 1, so HF-vs-mobius parity exercises the
shared path. TestBuildGemma3nKvSharing covers the cache I/O, absent
initializers, per-type source mapping, state_dict pruning, and the
every-layer-shared rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ORT-GenAI uses ``decoder.num_hidden_layers`` to decide how many
``past_key_values.%d.{key,value}`` bindings to create, so it must match
the exported graph rather than the architecture. ``from_config`` read it
straight off the model config, which is wrong for KV-layer-sharing
models: their trailing layers borrow K,V from an earlier layer and own
no cache entry.

This is a pre-existing bug affecting the shipping gemma4 export, not
just gemma3n — a 4-layer / 2-shared gemma4 build emits 2 KV cache pairs
but wrote ``num_hidden_layers: 4``. Cached google/gemma-4-E2B-it has 35
layers with 20 shared.

``_write_genai_config`` already introspects the decoder graph for its
input names and GQA support, so count the ``past_key_values.{i}.key``
inputs there too and pass the result through. Static-cache exports use
``key_cache.{i}`` instead and fall back to the config value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemma 3n sparsifies the MLP gate branch on its early layers. HF
(Gemma3nTextMLP._gaussian_topk) treats each row of gate_proj as a
Gaussian sample, derives the value below which `sparsity` of the mass
falls, and clamps everything under it to zero *before* the activation:

    cutoff = mean(x) + std(x, unbiased=False) * Phi^-1(sparsity)
    x = relu(x - cutoff)

E4B ships activation_sparsity_pattern = 0.95 for layers 0-9 and 0.0 for
layers 10-34, so nearly a third of the decoder was running a materially
different MLP than the checkpoint was trained with. mobius used the
plain components.MLP everywhere and never read the field —
`grep activation_sparsity src/mobius/` returned nothing.

Phi^-1 depends only on the config, so it folds into a Python float at
build time via statistics.NormalDist().inv_cdf() — no ONNX erfinv
needed. It matches torch.distributions.Normal(0, 1).icdf() to ~4e-7,
well inside float32.

- Gemma3nConfig.activation_sparsity_pattern, populated from the HF
  config (verified: 35 floats for E4B, non-zero on layers 0-9).
- Gemma3nMLP overrides MLP.forward to insert the cutoff between
  gate_proj and act_fn when sparsity is non-zero, and rejects a pattern
  that is out of range or too short to cover every layer. Zero-sparsity
  layers keep the plain path and emit no extra ops.

The tiny gemma3n configs now set [0.95, 0.0], covering both branches of
the fork, so the existing HF-vs-mobius parity harness exercises it:
without the cutoff, cosine similarity against HF drops from 0.996 to
0.78 (max_abs_diff 0.46). gemma3n_test.py additionally compares the
ONNX subgraph against a verbatim port of HF's _gaussian_topk under ORT,
and checks 0.95 sparsity keeps ~5% of a Gaussian row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for the Gemma 3n multimodal export: extract the MobileNet-V5
vision tower and USM Conformer audio tower configs so the component
builders have something to read. No graph changes yet — the registry
still maps "gemma3n" to the text-only decoder.

Verified against google/gemma-3n-E4B-it: every field below matches the
checkpoint, resolved identically whether from_transformers() is handed
the outer config or the text sub-config plus parent.

- VisionConfig gains vocab_offset/vocab_size (the image soft-token id
  range [262144, 262272) the multimodal embedder rebases into its own
  128-entry table), architecture (the timm spec name — MobileNet-V5 has
  no per-layer config, so the name is the only handle on the block
  layout), do_pooling, and rms_norm_eps.
- Gemma3nAudioConfig mirrors HF's field names verbatim (conf_* for the
  Conformer stack, sscp_* for the strided conv subsampler), so the hook
  is a copy rather than a remapping.
- Gemma3nMultiModalConfig extends Gemma3nConfig (the decoder is
  unchanged) with the fixed soft-token counts: 256 for a 768x768 image
  (16x16 grid), 188 for audio. It also lifts audio_token_id to the top
  level, which no generic extractor populates for gemma3n.
- The vision hook pins image_size=768: HF ships no image_size,
  MobileNet-V5 has no dynamic-resolution path, and the checkpoint's
  processor resizes to a fixed 768x768. Pinning it here keeps the graph's
  pixel_values shape and the generated preprocessing pipeline in sync.

Both hooks are bare (unfiltered) because build() may dispatch on
"gemma3n_text" while the gemma3n parent holds the sub-configs. The
predicate therefore reads the parent model_type off parent_config, not
off the `parent_config or config` composite — the composite form made
the hooks fire for *any* dispatched model_type whenever the config
itself was a gemma3n one, which the existing cross-contamination guards
in _extractors_test.py caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Gemma 3n vision tower is MobileNet-V5-300m, not SigLIP: HF maps
gemma3n_vision to TimmWrapperModel, so there is no HF-side module to
port and timm is deliberately not a mobius dependency. This adds a
native onnxscript implementation driven by an explicit block-spec table
transcribed from the mobilenetv5_300m_enc checkpoint (84 blocks across
4 stages: EdgeResidual, UIB, and multi-query attention 2D), plus the
Multi-Scale Fusion Adapter that fuses the last two stages
(640 + 1280 -> 1920) into 256 soft tokens.

Two things the checkpoint forces, both easy to get wrong:

* The 116 tensors timm names `bn.weight` are RmsNorm2d, not BatchNorm —
  there is no bn.bias, running_mean, running_var or
  num_batches_tracked anywhere in the checkpoint. BatchNorm2d would
  demand four initializers that do not exist, so RmsNorm2d (channel-axis
  RMS, scale-only) is added to _conv.py.
* timm builds this tower with TensorFlow-style SAME padding, which is
  asymmetric when the total pad is odd (extra pixel on the end).
  Symmetric k//2 padding shifts every strided feature map by a pixel.

Conv2d/Conv2dNoBias therefore now accept an ONNX-order
[top, left, bottom, right] 4-tuple for `padding` alongside the existing
int. Folding SAME padding into the Conv `pads` attribute (rather than
emitting Pad nodes) keeps static shape inference intact through the
whole tower, and keeps every conv going through Module.__call__ — which
is what qualifies parameter names and registers initializers.

Validated against timm with identical weights: max abs diff 8.1e-06 at
256x256 and 5.2e-06 at 768x768, cosine 1.000000 at both. Parameter
names and shapes are an exact 548/548 match against
google/gemma-3n-E4B-it.

The 47 new tests cover the SAME-padding amounts, RmsNorm2d's reduction
axis and scale broadcast, the block-spec table, the weight-name
contract, per-block ONNX execution, and the resolution flow. Whole-tower
assertions stay at the graph level: 300M parameters is past protobuf's
2 GB serialization ceiling once weights are attached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemma3nMultimodalEmbedder bridges both modality towers into the text
embedding space. Gemma 3n uses one per modality (model.embed_vision.*
and model.embed_audio.*, 4 tensors each), and HF drives each through two
distinct paths: the soft path embeds continuous encoder features (256
vision soft tokens, 188 audio), while the hard path embeds the reserved
placeholder token ids the processor splices into the prompt, via a
128-row table offset by vocab_offset. Both paths are dispatched from a
single forward, matching HF's signature — reaching into a submodule's
parameters from outside Module.__call__ would leave them unqualified and
unregistered, so the branch is resolved at graph-build time instead.
One instance serves both paths in one graph, which the audio path needs.

embedding_post_projection_norm is scale-free: HF builds it with
with_scale=False and the checkpoint ships no tensor for it. gemma4
already had a private class for exactly this, so it is promoted to
components as ScaleFreeRMSNorm and gemma4's four call sites now use the
shared one rather than duplicating it.

Verified against HuggingFace's Gemma3nMultimodalEmbedder: both paths
match to 4.8e-07. Weight names are an exact match for the checkpoint, so
preprocess_weights needs no renaming hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4 of gemma-3n-E4B-it multimodal export: the 269 tensors under
model.audio_tower. — a two-stage strided conv subsampler (SSCP) plus 12
conformer blocks. There is no output projection; that lives in
model.embed_audio (Gemma3nMultimodalEmbedder, added in the previous commit).

The conformer feed-forward and light-conv1d sub-blocks are Gemma 4's,
identical in both weight names and arithmetic, so Gemma4FeedForward and
Gemma4LightConv1d gain a linear_cls hook and are reused directly. Gemma 3n's
checkpoint ships no activation-clipping bounds, so the ClippableLinear default
would demand four initializers per projection that do not exist.

Three things genuinely diverge and are implemented here:

* _CumulativeGroupNorm — group statistics accumulated over the time axis via
  CumSum, where Gemma 4 uses a plain per-frame LayerNorm. HF's masking support
  in this layer is vestigial (it builds an all-ones mask unconditionally), so
  the element count is exactly (t+1)*F*C and HF's zero-count guard and trailing
  mask multiply are both no-ops.
* Reverse-causal time padding (0, kernel_h-1) plus fixed frequency padding
  (1, 1) in the SSCP convolutions, folded into the ONNX Conv pads attribute so
  shape inference still propagates.
* Attention: an explicit relative-position projection, no key rescaling, and a
  13-key window where Gemma4Attention admits 12 — so that class cannot be
  subclassed. The logit cap lands after the rel-pos bias and before the mask,
  which also rules out the ONNX Attention op's native softcap.

HF's chunked attention is flattened to full T×T attention, which is exactly
equivalent offline: working the block mask through, query i attends exactly the
keys j with -R <= i-j <= L. Mask polarity follows the mobius convention
(True = valid), the negation of HF's audio_mel_mask.

Every learned tensor keeps its HF name; the only extra initializers are the
derived relative_position_embedding.sin_emb constants.

Tests diff the whole encoder against HF over five sequence lengths, three
context configurations (including a lookahead window and a zero-length
history), a batch with differing valid lengths, an interior mask hole, and a
gradient clamp small enough to actually bind — plus the 269-tensor name/shape
contract, the 1024-wide projection the frequency arithmetic has to produce,
initializer qualification, and mask polarity. 18 of 18 seeded mutations of the
component are caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits Gemma 3n into the four graphs the ORT-GenAI multimodal pipeline
needs, following the gemma3/gemma4 pattern:

- _Gemma3nDecoderModel takes inputs_embeds + per_layer_inputs, keeping the
  4.7 GB embed_tokens_per_layer table out of the decoder while preserving
  the token-embedding term HF's project_per_layer_inputs adds.
- _Gemma3nVisionEncoderModel runs MobileNet-V5 -> reshape/transpose ->
  scale -> embed_vision soft path (mirrors get_image_features).
- _Gemma3nAudioEncoderModel runs the USM conformer -> embed_audio soft path.
- _Gemma3nEmbeddingModel does the scaled token lookup, the per-layer
  tables, and image/audio fusion, reusing gemma3's zero-pad Gather guard so
  a decode step's empty [0, hidden] feature tensor cannot gather OOB.
- Gemma3nMultiModalModel.preprocess_weights routes the HF checkpoint's five
  prefixes to the right components, synthesizes lm_head from the tied token
  embedding (E4B ships no lm_head key), and duplicates the embedder weights
  into both the tower and the embedding graph.

Also threads final_logit_softcapping (30.0 in every published Gemma 3n
config) through Gemma3nConfig and applies it in both the text-only and
multimodal LM heads, and makes Gemma3nTextModel.forward accept a
precomputed per_layer_inputs. _compute_per_layer_inputs now raises when
input_ids is absent instead of silently dropping the token-embedding term
and its 1/sqrt(2) scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the four sub-models into a package via Gemma3nTask, closely
following Gemma4Task (which already solves the same 4-way split with
optional audio):

- vision input pixel_values [batch, 3, image_size, image_size] — fixed
  NCHW, since MobileNet-V5 has no dynamic-resolution path
- audio inputs input_features [B, T, feat] + input_features_mask [B, T]
- declare_component_presence / declare_optional_input so a checkpoint
  without an audio tower still produces a legal 3-model package

The "gemma3n" registry key now maps to Gemma3nMultiModalModel instead of
Gemma3nCausalLMModel. That key previously produced a silently vision- and
audio-blind text decoder: Gemma3nCausalLMModel.preprocess_weights deletes
every vision_tower./audio_tower. key, so exporting google/gemma-3n-E4B-it
yielded a package that could not accept images or audio at all.
"gemma3n_text" keeps the text-only decoder, and _TEXT_ONLY_MODEL_TYPE maps
gemma3n -> gemma3n_text so text_only=True still works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends the gemma3 fixed-resize branch to cover gemma3n: MobileNet-V5 takes
the same fixed NCHW pixel_values contract as gemma3's SigLIP, so the generic
VLM branch's smart_resize (variable HxW, no Permute3D) would produce a
tensor the tower cannot accept.

Two behavioural differences from gemma3, both verified against the E4B
checkpoint's SiglipImageProcessorFast:

- image_size defaults to 768 (gemma3 uses 896).
- Normalize is *omitted*, giving a 5-step pipeline. The processor sets
  do_normalize=False, so the tower is trained on [0, 1] pixels; emitting
  Normalize with mean/std 0.5 would map them to [-1, 1] and silently
  degrade every caption. An HF processor that reports do_normalize=True
  still gets the 6-step pipeline.

Also maps the unwrapped model_type "gemma3n_text" to ORT model type
"gemma3n" for VLM packages. Deliberately not aliased to "gemma3": the
package threads per_layer_inputs (and optional audio) that gemma3's ORT
pipeline does not bind. onnxruntime-genai 0.14.1 has no gemma3n pipeline
yet, so this currently trips the existing "could not determine ORT-GenAI
model type" warning rather than mis-wiring the graph.

Tests cover the 5-step pipeline, the HF do_normalize override, the
gemma3n_text -> gemma3n mapping, and a regression guard that gemma3 still
gets its Normalize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Move the "gemma3n" test config from ALL_CAUSAL_LM_CONFIGS to VL_CONFIGS
  so the parametrized vision-language suites pick it up, and add a
  vl_overrides() helper for direct construction. The tiny vision config
  respects MobileNet-V5's image_size rules (size % 32 == 0 and
  (size // 16) % 16 == 0, so 256 is the smallest legal value).
- Add test_gemma3n_multimodal_graph and the ..._without_audio variant to
  build_graph_test.py, asserting the 4- and 3-model package shapes and the
  pixel_values / input_features / per_layer_inputs graph I/O.
- Drop the three now-unreachable "gemma3n" entries from
  synthetic_parity_test.py (_ATOL_OVERRIDES, _HF_EXTRA_CONFIG,
  _HF_MODEL_TYPE_OVERRIDES). None of those dicts has a staleness guard, so
  they were dead rather than failing. The gemma3n_text entries still cover
  text-only parity.
- Relabel testdata/cases/causal-lm/gemma3n.yaml as gemma3n_text: that case
  asserts a text-generation package, which "gemma3n" no longer builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two existing gemma3n_text parity entries each disable half of the
feature interaction: one has mixed layer_types with num_kv_shared_layers=0,
the other has sharing on but a single layer type. Neither reaches the
configuration the real E4B checkpoint uses (35 mixed layers,
num_kv_shared_layers=15), where HF picks a *different* KV source layer per
attention type -- a sliding shared layer borrows from the last pre-cutoff
sliding layer, a full-attention one from the last pre-cutoff full layer.

Add a 4-layer entry with layer_types [sliding, full, sliding, full] and
num_kv_shared_layers=2, so prev_layers is [sliding, full] and layer 2 must
borrow from layer 0 while layer 3 borrows from layer 1. Also keeps
activation sparsity on for a subset of layers.

Verified load-bearing by mutation: replacing the per-type lookup in
Gemma3nAttention with a plain `len(prev_layers) - 1` (always borrow from the
last pre-cutoff layer, ignoring type) fails this entry at cosine=0.881 with
argmax_match=False, while both pre-existing entries still pass. Getting this
wrong yields a loadable graph with plausible-looking attention, so the
shape-level tests do not catch it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gemma3n audio tower was exported but nothing referenced it: no feature
extraction config was written, and the speech section of genai_config.json
carried neither a config_filename nor an input mapping the runtime accepts.

Three pieces:

- _write_audio_processor_config gains a gemma3n branch. It reuses gemma4's
  Gemma4LogMel op but not its attributes -- Gemma3nAudioFeatureExtractor is a
  different filterbank (32 ms frames, 125-7600 Hz, 0.97 preemphasis, FFT
  overdrive on, 1e-5 mel floor). The upstream frame/hop are in samples and are
  converted to the milliseconds the op takes.

- The speech section now names that file. ORT-GenAI rejects a speech section
  that sets filename without config_filename outright, so leaving it off turns
  a missing-file error into a load-time throw.

- The speech input mapping is hardcoded rather than introspected. Unlike the
  decoder and vision sections, model.speech.inputs keys are a closed set the
  runtime defines (audio_embeds / attention_mask / audio_sizes /
  audio_projection_mode); an identity map from graph input names is rejected
  with 'model:speech:inputs: Unknown value "input_features"'.

Also point the vision section at processor_config.json, the file gemma3n
actually writes -- it shares gemma3's fixed-resize branch, not gemma4's, so
with_vision's image_processor.json default named a file that never exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects in the processor_config.json that every mobius VLM export
writes. All three are silent: the pipeline is well-formed, the model loads,
and the captions are merely wrong.

1. ConvertRGB. ort-extensions' convert_to_rgb unconditionally swaps R and B --
   it exists to fix up a BGR decode. DecodeImage(color_space="RGB") already
   emits RGB, so chaining the two handed the encoder BGR. Drop the step from
   every pipeline. Measured against HF's own processor on a 1920x1242 JPEG,
   this alone put the pixel tensor 22% (relative L2) away from the reference.

2. Resize interpolation. ort-extensions defaults to CUBIC; HF image processors
   overwhelmingly ship resample=2, PIL BILINEAR. The Resize step now carries an
   explicit interpolation derived from image_processor.resample, mapped through
   the PIL constants. PIL BOX/HAMMING have no ort-extensions counterpart, so
   those log a warning and fall back rather than crash.

3. size handling. transformers >= 5 returns a SizeDict from
   image_processor.size, and SizeDict is not a dict subclass -- so the
   isinstance(size, dict) guard silently discarded HF's real size and left
   whatever default the caller hardcoded. Normalise through _size_mapping()
   instead.

With all three fixed the pixel tensor lands 0.01% from HF's reference,
down from 22%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KV-shared layers (those at or after first_kv_shared_layer_idx) borrow the
already-RoPE'd K,V of the last non-shared layer of the same type. Because
those tensors already span past + current, the layer passes them whole and
supplies no past_key/past_value -- so at decode q_len=1 while kv_len=total.

The opset-24 Attention spec aligns its built-in causal mask UPPER-LEFT
("the attention masking has the form of the upper left causal bias due to the
alignment"), which pins that lone query to key 0 and collapses those 15 layers
onto the BOS token. ORT <= 1.27 aligned it bottom-right -- non-conforming, and
it hid this entirely; 1.28 conforms and the decode output degenerates.

attention_bias from create_attention_bias already bakes causal + sliding +
padding keyed on absolute cumsum positions, so it carries causality on its own
at any q_len/kv_len ratio. Emit is_causal=0 on shared layers and let the
explicit mask do the work, and raise if a shared layer is ever built without
one. Non-shared layers keep is_causal=1: they pass past_key/past_value, which
takes the op's cache path (bottom-right aligned in every ORT version), and at
prefill q_len == kv_len makes the two alignments identical.

The regression test is a decode step against a full re-prefill. This is
prefill-vs-decode, so the existing synthetic parity sweep -- prefill only --
could not have caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ArchitectureConfig.from_transformers gates attn_qk_norm on a model-type
allowlist that gemma3n was never added to, so Gemma3nAttention built neither
q_norm nor k_norm and the weights were dropped on the floor.

They are load-bearing rather than cosmetic. HF hardcodes scaling = 1.0 instead
of head_dim**-0.5 precisely because those norms leave Q and K at unit RMS;
without them the logits are scaled by |q||k| and softmax degenerates toward a
one-hot argmax.

Adding them takes the text-path synthetic parity max diff from ~0.094 to
~0.026, so tighten the tolerance to 0.05 to keep the fix from silently
regressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The causal-LM case pointed at google/gemma-3n-E2B-pt and carried
skip_reason "Model not yet published on HuggingFace." No -pt checkpoint was
ever published -- only the -it releases, which are multimodal. Follow the
gemma-4-e2b precedent and drive the text-only path from an -it checkpoint via
model_type "gemma3n_text", and drop the skip.

Add the E4B multimodal case alongside it, so the 4-model split
(decoder + vision + audio + embedding) is covered too.

_TEST_MODEL_IDS gets the same treatment for both keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NishantN2005
NishantN2005 requested review from a team and a lite review from Copilot August 10, 2026 19:28

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lintrunner found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Comment thread src/mobius/components/_conv.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Enables full multimodal export for Gemma 3n (google/gemma-3n-E4B-it) by splitting the package into dedicated decoder / vision / audio / embedding sub-models, and updates the surrounding config + export plumbing so downstream runtimes (notably ORT-GenAI) can wire the graphs correctly (including KV-layer-sharing cache contracts and processor config files).

Changes:

  • Add a Gemma 3n multimodal task and model/package contract (3- or 4-model split depending on audio).
  • Implement Gemma 3n’s modality-specific components (MobileNet-V5 vision tower, multimodal embedder, USM audio tower coverage) plus shared ScaleFreeRMSNorm.
  • Update testing + ORT-GenAI export/config generation to match new I/O contracts (processor pipelines, KV cache layer counts, model_type routing).

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/synthetic_parity_test.py Removes gemma3n multimodal entry from causal-LM parity suite; tightens gemma3n_text tolerance and unpins KV-sharing config escape hatch.
tests/build_graph_test.py Adds multimodal Gemma3n graph-build tests and adjusts KV-cache assertions for KV-sharing models.
tests/_test_configs.py Adds Gemma3n multimodal tiny config in VL_CONFIGS, expands gemma3n_text coverage for KV sharing + sparsity, and introduces vl_overrides().
testdata/cases/vision-language/gemma-3n-e4b-it.yaml New VLM case for Gemma 3n E4B-it multimodal export.
testdata/cases/causal-lm/gemma3n.yaml Switches causal-lm case to gemma3n_text driven from an -it checkpoint.
src/mobius/tasks/_gemma3n.py New Gemma3nTask implementing decoder/vision/audio/embedding graph split and contracts.
src/mobius/tasks/_causal_lm.py Uses kv_cache_layer_count() hook to size KV-cache inputs for KV-sharing decoders.
src/mobius/tasks/init.py Exposes Gemma3nTask and registers task key gemma3n.
src/mobius/models/gemma4.py Replaces private scale-free RMSNorm implementation with shared ScaleFreeRMSNorm.
src/mobius/models/gemma4_test.py Updates scale-free RMSNorm unit test to import from components.
src/mobius/models/gemma3n_test.py New tests for Gemma3n activation sparsity, KV-sharing masking behavior, and multimodal weight renaming/coverage.
src/mobius/models/init.py Exports Gemma3nMultiModalModel.
src/mobius/integrations/ort_genai/genai_config.py Adds num_kv_cache_layers override for ORT-GenAI KV binding count; clarifies audio config requirements.
src/mobius/integrations/ort_genai/genai_config_test.py Adds coverage for num_kv_cache_layers override behavior.
src/mobius/integrations/ort_genai/auto_export.py Fixes/extends processor config writing (no ConvertRGB, HF resample→Resize interpolation, SizeDict handling), adds Gemma3n processor branches, and counts KV-cache layers from graph inputs.
src/mobius/integrations/ort_genai/auto_export_test.py Updates vision pipeline expectations, adds Gemma3n-specific processor/config coverage, and tests KV-cache layer counting helper.
src/mobius/components/_rms_norm.py Introduces shared ScaleFreeRMSNorm.
src/mobius/components/_mobilenetv5.py New MobileNet-V5-300m encoder implementation for Gemma3n vision tower.
src/mobius/components/_mobilenetv5_test.py New unit tests validating MobileNet-V5 spec table, naming/shape contracts, SAME padding, and ONNX execution.
src/mobius/components/_gemma4_audio.py Parameterizes certain Gemma4 audio blocks to allow Gemma3n to use non-clippable linear projections.
src/mobius/components/_gemma3n_embedder.py New Gemma3n multimodal embedder component (soft/hard paths).
src/mobius/components/_gemma3n_embedder_test.py New unit tests for embedder checkpoint contract and HF numerical parity.
src/mobius/components/_gemma3n_audio_test.py New unit tests for Gemma3n USM audio encoder contract, masking, and HF numerical parity.
src/mobius/components/_conv.py Extends Conv2d padding handling to accept ONNX-order 4-tuples for asymmetric padding; adds RmsNorm2d.
src/mobius/components/init.py Exports Gemma3n components, MobileNetV5Encoder, RmsNorm2d, and ScaleFreeRMSNorm.
src/mobius/_registry.py Splits gemma3n vs gemma3n_text registrations; adds multimodal mapping and text-only aliasing rules; updates default model IDs.
src/mobius/_configs/per_model/_gemma3n_vision.py New gemma3n vision extractor hook for MobileNet-V5 tower fields + fixed image size.
src/mobius/_configs/per_model/_gemma3n_audio.py New gemma3n audio extractor hook producing Gemma3nAudioConfig.
src/mobius/_configs/per_model/init.py Registers new gemma3n per-model hooks.
src/mobius/_configs/_sub_configs.py Extends VisionConfig for gemma3n fields and adds Gemma3nAudioConfig dataclass.
src/mobius/_configs/_extractors_test.py Adds tests ensuring gemma3n vision/audio hooks fire correctly and don’t leak to unrelated model types.
src/mobius/_configs/_base.py Fixes intermediate_size coercion, adds gemma3n model types to special-case handling, and adds gemma3n KV-sharing + sparsity fields/config class.
src/mobius/_configs/_base_test.py New tests for ArchitectureConfig.from_transformers intermediate_size list→scalar coercion.
src/mobius/_configs/init.py Exposes Gemma3nMultiModalConfig and Gemma3nAudioConfig.
src/mobius/init.py Re-exports Gemma3nMultiModalConfig.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/mobius/components/_conv.py Outdated
Comment thread testdata/cases/vision-language/gemma-3n-e4b-it.yaml
NishantN2005 and others added 3 commits August 11, 2026 21:37
Nothing behavioural. `ruff check` flagged 20 violations across the new
gemma3n files and `ruff format` disagreed on 11 of them:

- RUF012: `Gemma3nTask.model_roles` needs `ClassVar[dict[str, str]]`, the
  same annotation every other `ModelTask` already carries.
- RUF002/RUF003: the `x` in dimension comments (`768x768`, `[B, T, 128]`)
  was a U+00D7 multiplication sign.
- D205: three docstrings in `gemma3n_test.py` ran the summary into the body
  without a blank line.
- E501 / I001 in `_mobilenetv5_test.py`, plus `timm's` -> `Timm's` for the
  docstring capitalisation rule.
- PT011-adjacent float asserts in `_extractors_test.py` and
  `gemma3n_test.py` now go through `pytest.approx`, matching
  `_attention_test.py` and `_moe_test.py`.

`ruff check --config=pyproject.toml` and `ruff format --check` are both
clean on the 35 files this branch touches, and the branch test set is
unchanged at 1632 passed / 44 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both cases have existed since this branch added them, but neither had a
golden, and a missing golden makes `tests/e2e_golden_test.py` *skip* --
so the two jobs that would actually catch a wrong export have been silently
doing nothing.

Generated with `scripts/generate_golden.py --force`, float32 on CPU, per
the dtype each case declares:

- `causal-lm/gemma3n` from `google/gemma-3n-E2B-it` via `model_type`
  `gemma3n_text` (30 layers, `num_kv_shared_layers=10`), 20 new tokens.
- `vision-language/gemma-3n-e4b-it` from `google/gemma-3n-E4B-it`,
  `pipeline-cat-chonk.jpeg` at 768x768, 30 new tokens. HF captions it
  "A medium shot captures a Pallas's cat, also known as a manul, walking
  across a snowy surface."

All four now-unskipped tests pass locally against the f32 export:

    L4 prefill argmax  text-generation/gemma3n            PASSED  201s
    L4 prefill argmax  image-text-to-text/gemma-3n-e4b-it PASSED
    L5 generation      text-generation/gemma3n            PASSED  370s
    L5 generation      image-text-to-text/gemma-3n-e4b-it PASSED  738s

L5 reproduces every golden token, which is the first end-to-end evidence
that KV layer sharing and activation sparsity are right on a real
checkpoint -- the synthetic parity harness only ever saw tiny configs.

Note for CI: the L4/L5 workflows pass `--timeout=300`. These ran 201-738s
on CPU here; the A10 pool should be faster, but the multimodal L5 in
particular may need a longer per-test timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NishantN2005 and others added 3 commits August 13, 2026 22:58
Merging main brought in onnxruntime#475's test for the packed-Qwen image pipeline,
which asserts a ConvertRGB step this branch had already removed from
every VLM pipeline. Git merged the file cleanly because the conflict is
semantic, so all six unit-test combos went red on one assertion.

The removal stands: ort-extensions' convert_to_rgb swaps R and B
unconditionally -- it exists to fix up a BGR decode -- so chaining it
after DecodeImage(color_space="RGB") hands the encoder BGR. With it gone
the pixel tensor lands 0.01% from HF's reference instead of 22%. So this
updates the newer expectation rather than restoring the transform, and
says why in the docstring so it does not come back.

The pipeline is now DecodeImage, Resize, Rescale, Normalize, PatchImage,
which moves the qwen2_5_vl flag onto index 3 and PatchImage's attrs to
index 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on onnxruntime#474: _resolve_pads accepted any non-int padding
verbatim, so a PyTorch-style (h, w) pair became a 2-element ONNX pads
attribute. That is not caught here -- it surfaces later, either as a
shape-inference error far from the caller or, worse, as a silent
numerical mismatch once the values land on the wrong edges.

The sequence form is deliberately ONNX order [top, left, bottom, right],
so a 2-tuple cannot be padded out to a 4-tuple without guessing which
edges the caller meant. Raise instead, and say what to pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@justinchuby justinchuby 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.

Thanks!

@justinchuby
justinchuby enabled auto-merge (squash) August 13, 2026 20:24
@justinchuby
justinchuby merged commit 23f3da7 into onnxruntime:main Aug 13, 2026
16 of 20 checks passed
@NishantN2005
NishantN2005 deleted the fix/gemma3n-export branch August 13, 2026 20:25
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.

4 participants