Skip to content

Fix L4/L5 e2e harness for Gemma4 per_layer_inputs + BOOL audio mask - #318

Merged
justinchuby merged 1 commit into
mainfrom
fix-gemma4-e2e-tests
May 27, 2026
Merged

Fix L4/L5 e2e harness for Gemma4 per_layer_inputs + BOOL audio mask#318
justinchuby merged 1 commit into
mainfrom
fix-gemma4-e2e-tests

Conversation

@justinchuby

Copy link
Copy Markdown
Member

Problem

L4 / L5 e2e tests have been failing on main for every Gemma4 case since
#296 (Move Gemma4 per-layer embeddings to embedding model). Two distinct
root causes:

1. per_layer_inputs missing from decoder feed

After #296 the embedding sub-model emits a second output
per_layer_inputs and the decoder accepts it as a required input. The
e2e harness only wired the first embedding output (inputs_embeds)
into the decoder, so every multi-model Gemma4 path failed with:

ValueError: Required inputs (['per_layer_inputs']) are missing from input feed
(['inputs_embeds', 'past_key_values.0.key', ..., 'attention_mask', 'position_ids'])

Affected: text-only on multi-model (text-generation/gemma-4-e2b),
VL prefill (image-text-to-text/gemma-4-e2b-it, …-e4b-it), VL
generation (L5), speech-language prefill+generation.

2. input_features_mask dtype mismatch

The Gemma4 audio encoder (_gemma4.py:367) declares
input_features_mask as tensor(bool), but the harness unconditionally
cast every feature-extractor output to np.float32, producing:

InvalidArgument: Unexpected input data type. Actual: (tensor(float)), expected: (tensor(bool))

Even when the fallback all-True mask path was taken (line 1267), the
bool numpy array crashed ort_easy's DLPack-first conversion path
because DLPack has no native bool type code.

Fix

tests/e2e_golden_test.py

In every decoder-feed setup (5 sites: text-only multi-model prefill,
VL prefill, VL generation, speech-language prefill, speech-language
generation), wire any extra embedding outputs through to the decoder by
name. For models without per_layer_inputs the extra loop iteration is
a no-op:

elif name in emb_out:
    dec_feeds[name] = emb_out[name]

For both audio-encoder setup sites (L4 + L5 paths), honor the session's
declared input dtype:

target_dtype = audio_session.get_input_dtype(name) or np.float32
audio_feeds[name] = audio_processed[name].astype(target_dtype)

And use the session-declared dtype for the fallback all-True mask too.

src/mobius/_testing/ort_inference.py

Route bool numpy arrays through OrtValue.ortvalue_from_numpy directly
in _numpy_to_ort_value, bypassing ort_easy's DLPack-first path
(which has no bool type code).

Verification (locally on H200)

Test Before After
L4 text-generation/gemma-4-e2b FAIL (missing per_layer_inputs) PASS
L4 image-text-to-text/gemma-4-e2b-it FAIL (missing per_layer_inputs) PASS
L4 speech-language/gemma-4-e2b-it-audio FAIL (bool dtype mismatch) PASS
L5 image-text-to-text/gemma-4-e2b-it FAIL PASS
L5 speech-language/gemma-4-e2b-it-audio FAIL PASS

5 passed, 1 skipped (L5 text-gen is gated by integration markers under
fast runs), 0 failed. ruff check + ruff format --check both pass.

L1 + L3 + non-gemma e2e suites are unaffected (the embedding-output
loop is a generic no-op for models that don't emit extra outputs;
audio-mask changes only trigger when the session declares a bool input).

Why this didn't get caught earlier

The CI matrix only runs L4 / L5 in the affected-models lane, and #296
was tested standalone before this lane started exercising the multi-
model Gemma4 path through the e2e harness. The bool-mask issue is even
older — it was masked by the harness's unconditional astype(np.float32)
until the audio_encoder was upgraded to take a real BOOL mask.

… mask

After #296 moved Gemma4's per-layer input computation from the text
decoder into the embedding sub-model, the embedding model now emits a
second output (per_layer_inputs) and the decoder accepts it as a
required input. The e2e harness only wired the first embedding output
(inputs_embeds) into the decoder, so every multi-model Gemma4 path
(text-only on multi-model, VL prefill, VL generation, speech-language
prefill, speech-language generation) failed with:

  ValueError: Required inputs (['per_layer_inputs']) are missing from
  input feed (['inputs_embeds', ...])

Fix by passing any extra embedding outputs through to the decoder by
name. This is generic — for models without per_layer_inputs the extra
loop iteration just no-ops.

Separately, the speech-language audio encoder builds Gemma4's
input_features_mask as tensor(bool), but the harness unconditionally
cast feature-extractor outputs to float32 (and constructed the fallback
all-True mask as np.bool_ that then crashed ort_easy's DLPack path,
which has no bool type code). Fix by:

- Honoring the session's declared input dtype for each audio-encoder
  input (BOOL stays BOOL, FLOAT becomes float32, etc.).
- Routing bool numpy arrays through OrtValue.ortvalue_from_numpy
  directly in OnnxModelSession, bypassing ort_easy's DLPack-first path.

Verified locally on H200:
  L4 text-generation/gemma-4-e2b           PASS
  L4 image-text-to-text/gemma-4-e2b-it     PASS
  L4 speech-language/gemma-4-e2b-it-audio  PASS
  L5 image-text-to-text/gemma-4-e2b-it     PASS
  L5 speech-language/gemma-4-e2b-it-audio  PASS
(L5 text-generation/gemma-4-e2b is gated by integration markers and
skipped under fast runs.)

ruff check + format pass.

Signed-off-by: justinchuby <11205048+justinchuby@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 32cbe86fe7c7b4

Model Sub-model Changes Status

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

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

Fixes the L4/L5 end-to-end golden test harness for Gemma4 multi-model pipelines by correctly wiring the embedding model’s extra outputs into the decoder feed and by respecting declared ORT input dtypes for audio inputs (notably tensor(bool) masks). Also updates the ORT inference wrapper to safely handle bool NumPy arrays without going through a DLPack conversion path that can’t represent bool.

Changes:

  • Pass through additional embedding outputs (e.g. per_layer_inputs) to the decoder by matching decoder input names.
  • Cast audio encoder inputs (including fallback input_features_mask) to the session-declared input dtype instead of forcing float32.
  • Bypass onnxruntime_easy’s DLPack-first conversion for bool NumPy arrays by constructing OrtValue directly.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
tests/e2e_golden_test.py Wires embedding outputs into decoder feeds across L4/L5 paths and casts audio inputs/masks using session-declared dtypes.
src/mobius/_testing/ort_inference.py Adds bool-aware NumPy→OrtValue conversion to avoid DLPack limitations for tensor(bool) inputs.

@github-actions

Copy link
Copy Markdown

Performance Comparison

Comparing 32cbe86fe7c7b4

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 53 53 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 61 61 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 59 59 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 61 61 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@codecov

codecov Bot commented May 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/mobius/_testing/ort_inference.py 0.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@justinchuby
justinchuby merged commit 3ce0444 into main May 27, 2026
23 of 24 checks passed
@justinchuby
justinchuby deleted the fix-gemma4-e2e-tests branch May 27, 2026 05:31
justinchuby added a commit that referenced this pull request May 28, 2026
## Summary

After #296 ("Move Gemma4 per-layer embeddings to embedding model") the
Gemma4 embedding sub-model emits a second output `per_layer_inputs`
(`[B, S, num_layers * per_layer_dim]`) that the decoder consumes on
every step. `examples/gemma4_multimodal.py` wasn't updated to forward
this output, so any Gemma 4 build with `hidden_size_per_layer_input > 0`
(e.g. `google/gemma-4-E2B-it`) crashes at the first `decoder.run()`:

```
ValueError: Required inputs (['per_layer_inputs']) are missing from input feed
(['inputs_embeds', ..., 'attention_mask', 'position_ids'])
```

## Fix

- `prepare_decoder_feeds()`: accept an optional `per_layer_inputs`
argument and add it to the feeds dict when present. Builds without
per-layer inputs (`hidden_size_per_layer_input == 0`, e.g. larger Gemma
4 variants) keep working — the kwarg is optional.
- `generate()`: pull `embed_out.get("per_layer_inputs")` and pass it to
`prepare_decoder_feeds` on every step.

## Verification

```
$ python examples/gemma4_multimodal.py --mode text --prompt "What is 2+2?"
...
📝  TEXT-ONLY GENERATION
================================================================
Prompt: What is 2+2?
----------------------------------------------------------------
2 + 2 = **4**
```

## Related

This is the example-side counterpart to #318 which fixed the same gap in
the L4/L5 e2e test harness.

---------

Signed-off-by: justinchuby <11205048+justinchuby@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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.

2 participants