Skip to content

Commit fe7c7b4

Browse files
committed
tests: fix L4/L5 e2e harness for Gemma4 per_layer_inputs + BOOL audio 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>
1 parent 32cbe86 commit fe7c7b4

2 files changed

Lines changed: 61 additions & 8 deletions

File tree

src/mobius/_testing/ort_inference.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,18 @@ def _ort_value_to_numpy(value: ort.OrtValue) -> np.ndarray:
6666

6767

6868
def _numpy_to_ort_value(value: np.ndarray) -> ort.OrtValue:
69-
"""Convert a NumPy array to an OrtValue, supporting ml_dtypes (bf16, etc).
69+
"""Convert a NumPy array to an OrtValue, supporting ml_dtypes and bool.
7070
7171
``onnxruntime_easy.ort_value`` prefers the DLPack path, which is broken
72-
for ml_dtypes scalars (NumPy's __dlpack__ rejects non-standard dtypes).
73-
Route ml_dtypes arrays through ``ortvalue_from_numpy_with_onnx_type``
74-
instead.
72+
for ml_dtypes scalars (NumPy's __dlpack__ rejects non-standard dtypes)
73+
and for bool (DLPack has no native bool type code). Route those arrays
74+
through the explicit numpy-to-OrtValue APIs instead.
7575
"""
7676
if isinstance(value, np.ndarray):
77+
if value.dtype == np.bool_:
78+
# ORT supports tensor(bool) natively but DLPack does not, so
79+
# bypass ort_easy's DLPack-first path.
80+
return ort.OrtValue.ortvalue_from_numpy(np.ascontiguousarray(value))
7781
try:
7882
import ml_dtypes
7983
except ImportError: # pragma: no cover

tests/e2e_golden_test.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,10 @@ def _run_vision_language_prefill(
666666
)
667667
else:
668668
dec_feeds[name] = np.arange(seq_len, dtype=np.int64).reshape(1, -1)
669+
elif name in emb_out:
670+
# Gemma4 (when hidden_size_per_layer_input > 0) emits a second
671+
# embedding output ``per_layer_inputs`` that the decoder needs.
672+
dec_feeds[name] = emb_out[name]
669673
outputs = dec_session.run(dec_feeds)
670674
finally:
671675
dec_session.close()
@@ -862,6 +866,12 @@ def _run_vl_generation(
862866
else:
863867
next_decode_pos = prompt_seq_len
864868

869+
# Wire extra embedding outputs the decoder expects by name
870+
# (e.g. Gemma4 ``per_layer_inputs``).
871+
for name in dec_session.input_names:
872+
if name not in dec_feeds and name in emb_out:
873+
dec_feeds[name] = emb_out[name]
874+
865875
prefill_out = dec_session.run(dec_feeds)
866876
logits = prefill_out["logits"]
867877
next_token = np.argmax(logits[:, -1, :], axis=-1, keepdims=True).astype(np.int64)
@@ -918,6 +928,12 @@ def _run_vl_generation(
918928
else:
919929
step_feeds["position_ids"] = np.array([[next_decode_pos]], dtype=np.int64)
920930

931+
# Wire extra embedding outputs the decoder expects by name
932+
# (e.g. Gemma4 ``per_layer_inputs``).
933+
for name in dec_session.input_names:
934+
if name not in step_feeds and name in step_emb_out:
935+
step_feeds[name] = step_emb_out[name]
936+
921937
step_out = dec_session.run(step_feeds)
922938
logits = step_out["logits"]
923939
next_token = np.argmax(logits[:, -1, :], axis=-1, keepdims=True).astype(np.int64)
@@ -1069,6 +1085,11 @@ def _run_text_only_multimodel_prefill(
10691085
dec_feeds[name] = np.ones((1, seq_len), dtype=np.int64)
10701086
elif name == "position_ids":
10711087
dec_feeds[name] = np.arange(seq_len, dtype=np.int64).reshape(1, -1)
1088+
elif name in emb_out:
1089+
# Gemma4 (when hidden_size_per_layer_input > 0) emits a second
1090+
# embedding output ``per_layer_inputs`` that the decoder needs.
1091+
# Wire any extra embedding outputs through by name.
1092+
dec_feeds[name] = emb_out[name]
10721093
outputs = dec_session.run(dec_feeds)
10731094
finally:
10741095
dec_session.close()
@@ -1200,6 +1221,11 @@ def _run_phi4mm_multimodal_prefill(
12001221
"position_ids": np.arange(seq_len, dtype=np.int64).reshape(1, -1),
12011222
**kv_cache,
12021223
}
1224+
# Wire any extra embedding outputs the decoder expects by name
1225+
# (e.g. Gemma4 ``per_layer_inputs``).
1226+
for name in dec_session.input_names:
1227+
if name not in dec_feeds and name in emb_out:
1228+
dec_feeds[name] = emb_out[name]
12031229
outputs = dec_session.run(dec_feeds)
12041230
finally:
12051231
dec_session.close()
@@ -1252,7 +1278,11 @@ def _run_speech_language_prefill(
12521278
audio_feeds: dict[str, np.ndarray] = {}
12531279
for name in audio_session.input_names:
12541280
if name in audio_processed:
1255-
audio_feeds[name] = audio_processed[name].astype(np.float32)
1281+
# Cast to the session's declared dtype (e.g. input_features_mask
1282+
# is BOOL on Gemma4 audio encoder; the HF feature extractor may
1283+
# emit it as float or int).
1284+
target_dtype = audio_session.get_input_dtype(name) or np.float32
1285+
audio_feeds[name] = audio_processed[name].astype(target_dtype)
12561286
elif name == "input_features" and "input_features" in audio_processed:
12571287
audio_feeds[name] = audio_processed["input_features"].astype(np.float32)
12581288
# Provide all-True mask for single-clip inference when the model
@@ -1264,7 +1294,8 @@ def _run_speech_language_prefill(
12641294
and "input_features" in audio_feeds
12651295
):
12661296
feats = audio_feeds["input_features"]
1267-
audio_feeds["input_features_mask"] = np.ones(feats.shape[:2], dtype=np.bool_)
1297+
mask_dtype = audio_session.get_input_dtype("input_features_mask") or np.bool_
1298+
audio_feeds["input_features_mask"] = np.ones(feats.shape[:2], dtype=mask_dtype)
12681299
audio_out = audio_session.run(audio_feeds)
12691300
finally:
12701301
audio_session.close()
@@ -1352,6 +1383,10 @@ def _run_speech_language_prefill(
13521383
ndims = pos_shape[0] if isinstance(pos_shape[0], int) else 3
13531384
pos = np.tile(pos, (ndims, 1, 1))
13541385
dec_feeds[name] = pos
1386+
elif name in emb_out:
1387+
# Extra embedding outputs the decoder expects by name
1388+
# (e.g. Gemma4 ``per_layer_inputs``).
1389+
dec_feeds[name] = emb_out[name]
13551390
outputs = dec_session.run(dec_feeds)
13561391
finally:
13571392
dec_session.close()
@@ -1701,7 +1736,8 @@ def _run_speech_language_generation(
17011736
audio_feeds: dict[str, np.ndarray] = {}
17021737
for name in audio_session.input_names:
17031738
if name in audio_processed:
1704-
audio_feeds[name] = audio_processed[name].astype(np.float32)
1739+
target_dtype = audio_session.get_input_dtype(name) or np.float32
1740+
audio_feeds[name] = audio_processed[name].astype(target_dtype)
17051741
elif name == "input_features" and "input_features" in audio_processed:
17061742
audio_feeds[name] = audio_processed["input_features"].astype(np.float32)
17071743
# Provide all-True mask for single-clip inference when the model
@@ -1713,7 +1749,8 @@ def _run_speech_language_generation(
17131749
and "input_features" in audio_feeds
17141750
):
17151751
feats = audio_feeds["input_features"]
1716-
audio_feeds["input_features_mask"] = np.ones(feats.shape[:2], dtype=np.bool_)
1752+
mask_dtype = audio_session.get_input_dtype("input_features_mask") or np.bool_
1753+
audio_feeds["input_features_mask"] = np.ones(feats.shape[:2], dtype=mask_dtype)
17171754
audio_out = audio_session.run(audio_feeds)
17181755
finally:
17191756
audio_session.close()
@@ -1798,6 +1835,12 @@ def _run_speech_language_generation(
17981835
pos = np.tile(pos, (ndims_pos, 1, 1))
17991836
dec_feeds["position_ids"] = pos
18001837

1838+
# Wire extra embedding outputs the decoder expects by name
1839+
# (e.g. Gemma4 ``per_layer_inputs``).
1840+
for name in dec_session.input_names:
1841+
if name not in dec_feeds and name in emb_out:
1842+
dec_feeds[name] = emb_out[name]
1843+
18011844
prefill_out = dec_session.run(dec_feeds)
18021845
logits = prefill_out["logits"]
18031846
next_token = np.argmax(logits[:, -1, :], axis=-1, keepdims=True).astype(np.int64)
@@ -1841,6 +1884,12 @@ def _run_speech_language_generation(
18411884
else:
18421885
step_feeds["position_ids"] = np.array([[past_seq_len]], dtype=np.int64)
18431886

1887+
# Wire extra embedding outputs the decoder expects by name
1888+
# (e.g. Gemma4 ``per_layer_inputs``).
1889+
for name in dec_session.input_names:
1890+
if name not in step_feeds and name in step_emb_out:
1891+
step_feeds[name] = step_emb_out[name]
1892+
18441893
step_out = dec_session.run(step_feeds)
18451894
logits = step_out["logits"]
18461895
next_token = np.argmax(logits[:, -1, :], axis=-1, keepdims=True).astype(np.int64)

0 commit comments

Comments
 (0)