Skip to content

Commit d6aef68

Browse files
justinchubyCopilot
andauthored
Fix GPU integration/L4/L5 test failures: TF32, transformers 5.x vision renames, gemma3 multimodal (#350)
## Summary Ran the full integration + L4 (golden) + L5 (generation) suite on GPU (`MOBIUS_TEST_DEVICE=cuda`, 8× H200) to find regressions and long-standing bugs. Triaged 88 failures by root cause. **PR #338 (gemma4 bidirectional overlay + GQA-cap removal) introduced zero regressions** — every spot-checked failure reproduces identically on the parent commit `f972184`. This PR lands the verified, high-impact fixes. Remaining buckets (deep numeric/decode bugs, ref-API drift, env/external) are tracked separately and will follow. ## Fixes ### 1. Disable TF32 on GPU (`tests/conftest.py`) On Ampere+/Hopper the ORT CUDA EP uses TF32 for fp32 matmuls by default, while the PyTorch reference computes in true fp32. The ~1e-2 logit discrepancy spuriously failed **~35** fp32 numeric-parity tests (rtol/atol 1e-3). Set `NVIDIA_TF32_OVERRIDE=0` in conftest before any CUDA library initializes (uses `setdefault` so users can opt back in). Verified: with the env var unset, `gpt2` and `qwen2.5-0.5b` L4 now pass. ### 2. transformers ≥5.x flattened ViT/CLIP weight names (`src/mobius/models/vit.py`) transformers 5.x flattened the ViT state dict to `layers.N.*` with consolidated `attention.{q,k,v,o}_proj` / `mlp.fc1/fc2` names. The legacy rename map no longer matched, leaving graph initializers unfilled (ORT load failure). Added an **additive** new-naming branch (legacy 5.0–5.9 path preserved) and aligned the in-test torch reference modules with the mobius graph param names. ### 3. gemma3 multimodal vision encoder (`src/mobius/models/gemma3.py`) Two bugs broke the gemma-3 image-text-to-text pipeline: - The full-VLM `preprocess_weights` prefixed vision weights but didn't rename the vision MLP `fc1/fc2` → `up_proj/down_proj`, so FCMLP initializers were never filled (ORT load failure). - The vision encoder returned rank-3 `(batch, tokens, hidden)`, but the embedding sub-model declares `image_features` as rank-2 and gathers along axis 0. Squeeze the batch dim to honor the 2-D contract (matching the `PixtralVLTask` precedent and the ort-genai runtime). With both, **gemma-3-4b-it L4 golden passes** on CUDA. ## Verification - TF32 fix: `gpt2`, `qwen2.5-0.5b` L4 pass with env unset. - ViT/CLIP parity tests pass. - gemma-3-4b-it L4 golden passes; 25 gemma3 build/task unit tests pass. - `lintrunner` clean on all changed files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ## Architecture-diff notes (review follow-up) Two entries in the `arch-diff-bot` comment (`e005489 -> 2f13c7d`) deserve explanation: ### falcon: `Gelu` -> `Sigmoid + Mul` The bloom-GELU fix changed falcon's MLP from a hardcoded `activation="gelu"` to `activation=config.hidden_act or "gelu"` so `FCMLP` can be shared with Bloom (which needs `gelu_pytorch_tanh`). For a **real** falcon config this is a no-op: `ArchitectureConfig.hidden_act` resolves to `"gelu"` via `config.activation` (HF `FalconConfig` has no `hidden_act` and defaults `activation="gelu"`). The arch-diff only showed SiLU (`Sigmoid+Mul`) because the **synthetic test config** used the generic `_base_config` default `hidden_act="silu"`. Fixed by setting `hidden_act="gelu"` in falcon's synthetic config so it matches real Falcon. ### `RotaryEmbedding: num_heads: 2 -> 4` This is a **false positive** from the diff tool's positional node matching, *caused by* the SiLU regression above -- not a real change. Each falcon layer emits two `RotaryEmbedding` nodes: Q-rotary (`num_heads=4`) and K-rotary (`num_heads=2`, GQA `kv_heads=2`). Inserting `Sigmoid+Mul` per layer shifted all subsequent node indices by +2, so the tool aligned base's K-rotary (`num_heads=2`) against head's Q-rotary (`num_heads=4`). Per-head counts are unchanged at both base and head. Restoring `Gelu` removes the extra nodes, realigns indices, and makes this spurious entry disappear. --------- Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e005489 commit d6aef68

37 files changed

Lines changed: 1807 additions & 571 deletions

examples/phi4mm_multimodal.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
The model is split into 4 ONNX graphs, each running in its own
1717
ONNX Runtime session:
1818
19-
- **Vision**: ``pixel_values`` + ``image_sizes`` → ``image_features``
19+
- **Vision**: ``pixel_values`` + ``image_sizes`` +
20+
``image_attention_mask`` → ``image_features``
2021
(SigLIP encoder + projection)
2122
- **Speech**: ``audio_features`` + ``audio_sizes`` +
2223
``audio_projection_mode`` → ``audio_features``
@@ -111,12 +112,16 @@ def prepare_vision_feeds(
111112
"""Prepare feeds for the **vision** session.
112113
113114
Returns:
114-
``{"pixel_values": [1, 3, H, W], "image_sizes": [1, 2]}``
115-
ready for the vision ONNX model.
115+
``{"pixel_values": [crops, 3, H, W], "image_sizes": [1, 2],
116+
"image_attention_mask": [crops, P, P]}`` ready for the vision
117+
ONNX model (single image).
116118
"""
117-
pixel_values = _load_image(image_path, processor)
118-
image_sizes = np.array([[pixel_values.shape[-2], pixel_values.shape[-1]]], dtype=np.int64)
119-
return {"pixel_values": pixel_values, "image_sizes": image_sizes}
119+
pixel_values, image_sizes, image_attention_mask = _load_image(image_path, processor)
120+
return {
121+
"pixel_values": pixel_values,
122+
"image_sizes": image_sizes,
123+
"image_attention_mask": image_attention_mask,
124+
}
120125

121126

122127
def prepare_speech_feeds(
@@ -175,6 +180,8 @@ def prepare_decoder_feeds(
175180
inputs_embeds: np.ndarray,
176181
past_seq_len: int,
177182
past_kv: dict[str, np.ndarray],
183+
vision_gate: np.ndarray,
184+
speech_gate: np.ndarray,
178185
) -> dict[str, np.ndarray]:
179186
"""Prepare feeds for the **decoder** session.
180187
@@ -185,6 +192,10 @@ def prepare_decoder_feeds(
185192
inputs_embeds: ``[batch, cur_seq_len, hidden_size]`` float32.
186193
past_seq_len: Number of tokens already in the KV cache.
187194
past_kv: Dict of ``past_key_values.{i}.key/value`` arrays.
195+
vision_gate: Scalar LoRA gate emitted by the embedding model
196+
(1.0 if any image token is present, else 0.0).
197+
speech_gate: Scalar LoRA gate emitted by the embedding model
198+
(1.0 if audio present and no image, else 0.0).
188199
189200
Returns:
190201
Complete feeds dict for the decoder ONNX model.
@@ -197,6 +208,11 @@ def prepare_decoder_feeds(
197208
"inputs_embeds": inputs_embeds,
198209
"attention_mask": np.ones((batch_size, total_seq_len), dtype=np.int64),
199210
"position_ids": np.arange(past_seq_len, total_seq_len, dtype=np.int64)[np.newaxis, :],
211+
# The decoder declares vision_gate/speech_gate as required scalar
212+
# inputs; they are produced by the embedding model and select the
213+
# active per-modality LoRA adapter.
214+
"vision_gate": vision_gate,
215+
"speech_gate": speech_gate,
200216
**past_kv,
201217
}
202218

@@ -279,17 +295,27 @@ def _build_input_ids_vision_audio(
279295
# ---------------------------------------------------------------------------
280296

281297

282-
def _load_image(image_path: str, processor) -> np.ndarray:
298+
def _load_image(image_path: str, processor) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
283299
"""Load and preprocess an image using the HuggingFace processor.
284300
301+
Phi4MM's image processor performs an HD multi-crop transform and emits
302+
``input_image_embeds`` (the crop pixel tensor), ``image_sizes`` and an
303+
``image_attention_mask`` marking valid (non-padding) patches per crop.
304+
285305
Returns:
286-
``pixel_values`` as ``[1, 3, H, W]`` float32 numpy array.
306+
``(pixel_values [crops, 3, H, W], image_sizes [1, 2],
307+
image_attention_mask [crops, P, P])`` as float32/int64 arrays for a
308+
single image.
287309
"""
288310
from PIL import Image
289311

290312
img = Image.open(image_path).convert("RGB")
291313
processed = processor.image_processor(images=img, return_tensors="np")
292-
return processed["pixel_values"].astype(np.float32)
314+
# Single image -> index 0 of the leading num_images dimension.
315+
pixel_values = processed["input_image_embeds"][0].astype(np.float32)
316+
image_sizes = processed["image_sizes"][0:1].astype(np.int64)
317+
image_attention_mask = processed["image_attention_mask"][0].astype(np.float32)
318+
return pixel_values, image_sizes, image_attention_mask
293319

294320

295321
def _load_audio(
@@ -417,9 +443,15 @@ def generate(
417443
)
418444
embed_out = embedding_session.run(embed_feeds)
419445
inputs_embeds = embed_out["inputs_embeds"]
446+
# The embedding model also emits the per-modality LoRA gates derived
447+
# from input_ids; thread them into the decoder, which requires them.
448+
vision_gate = embed_out["vision_gate"]
449+
speech_gate = embed_out["speech_gate"]
420450

421451
# --- Decoder session ---
422-
decoder_feeds = prepare_decoder_feeds(inputs_embeds, past_seq_len, past_kv)
452+
decoder_feeds = prepare_decoder_feeds(
453+
inputs_embeds, past_seq_len, past_kv, vision_gate, speech_gate
454+
)
423455
outputs = decoder_session.run(decoder_feeds)
424456

425457
logits = outputs["logits"]

examples/phi4mm_ort_genai.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ def _write_genai_config(config, output_dir: str) -> None:
110110
"inputs_embeds": "inputs_embeds",
111111
"attention_mask": "attention_mask",
112112
"position_ids": "position_ids",
113+
"vision_gate": "vision_gate",
114+
"speech_gate": "speech_gate",
113115
"past_key_names": "past_key_values.%d.key",
114116
"past_value_names": "past_key_values.%d.value",
115117
},
@@ -132,6 +134,8 @@ def _write_genai_config(config, output_dir: str) -> None:
132134
},
133135
"outputs": {
134136
"inputs_embeds": "inputs_embeds",
137+
"vision_gate": "vision_gate",
138+
"speech_gate": "speech_gate",
135139
},
136140
},
137141
"vision": {
@@ -144,6 +148,7 @@ def _write_genai_config(config, output_dir: str) -> None:
144148
"inputs": {
145149
"pixel_values": "pixel_values",
146150
"image_sizes": "image_sizes",
151+
"image_attention_mask": "image_attention_mask",
147152
},
148153
"outputs": {
149154
"image_features": "image_features",

scripts/generate_golden.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,79 @@ def _generate_image_classification(case: TestCase, json_path: Path, device: str)
951951
)
952952

953953

954+
def _detection_forced_size(model_id: str, trust_remote_code: bool) -> dict | None:
955+
"""Return a fixed ``{height, width}`` size for object-detection export.
956+
957+
mobius exports object-detection models (e.g. YOLOS) at a *fixed* input
958+
resolution taken from ``config.image_size`` (position embeddings are not
959+
interpolated for arbitrary sizes). To keep the golden reference and the
960+
ONNX forward pass on the same footing, the HF image processor must be
961+
forced to emit exactly that resolution instead of its default
962+
aspect-preserving resize.
963+
"""
964+
import transformers
965+
966+
config = transformers.AutoConfig.from_pretrained(
967+
model_id, trust_remote_code=trust_remote_code
968+
)
969+
image_size = getattr(config, "image_size", None)
970+
if isinstance(image_size, (list, tuple)) and len(image_size) == 2:
971+
return {"height": int(image_size[0]), "width": int(image_size[1])}
972+
if isinstance(image_size, int):
973+
return {"height": image_size, "width": image_size}
974+
return None
975+
976+
977+
def _generate_object_detection(case: TestCase, json_path: Path, device: str) -> None:
978+
"""Generate golden data for object detection (e.g. YOLOS).
979+
980+
The model emits per-query class ``logits`` of shape
981+
``[batch, num_queries, num_labels + 1]``. The golden top-K is taken over
982+
the *last* query's class-logit vector to match ``compare_golden``, which
983+
slices ``logits[:, -1, :]``. The image processor is forced to the model's
984+
fixed export resolution so the golden and ONNX forward pass agree.
985+
"""
986+
import torch
987+
import transformers
988+
from PIL import Image
989+
990+
from mobius._testing.golden import save_golden_ref
991+
992+
processor = transformers.AutoImageProcessor.from_pretrained(
993+
case.model_id, trust_remote_code=case.trust_remote_code
994+
)
995+
model = transformers.AutoModelForObjectDetection.from_pretrained(
996+
case.model_id,
997+
torch_dtype=torch.float32,
998+
device_map=device,
999+
trust_remote_code=case.trust_remote_code,
1000+
).eval()
1001+
1002+
image = Image.open(Path("testdata") / case.images[0])
1003+
forced_size = _detection_forced_size(case.model_id, case.trust_remote_code)
1004+
proc_kwargs = {"images": image, "return_tensors": "pt"}
1005+
if forced_size is not None:
1006+
proc_kwargs["size"] = forced_size
1007+
processed = processor(**proc_kwargs)
1008+
pixel_values = processed["pixel_values"].to(device)
1009+
1010+
with torch.no_grad():
1011+
outputs = model(pixel_values=pixel_values)
1012+
# logits: [batch, num_queries, num_labels + 1] -> last query's class vector
1013+
last_logits = outputs.logits[0, -1, :].cpu().numpy()
1014+
golden = _extract_logits_golden(last_logits)
1015+
1016+
save_golden_ref(
1017+
json_path,
1018+
top1_id=golden["top1_id"],
1019+
top2_id=golden["top2_id"],
1020+
top10_ids=golden["top10_ids"],
1021+
top10_logits=golden["top10_logits"],
1022+
logits_summary=golden["logits_summary"],
1023+
input_ids=np.array([[0]], dtype=np.int64), # placeholder (no text input)
1024+
)
1025+
1026+
9541027
# ---- Phi4MM multimodal generator ----
9551028

9561029

@@ -1129,10 +1202,13 @@ def _generate_phi4mm_multimodal(case: TestCase, json_path: Path, device: str) ->
11291202
processor = transformers.AutoProcessor.from_pretrained(
11301203
case.model_id, trust_remote_code=True
11311204
)
1132-
# Load in bfloat16 to reduce memory (14B model)
1205+
# Load in float32 to match the f32 runtime used by the L4/L5 tests.
1206+
# bf16 goldens produced flat/tied logit distributions that caused
1207+
# argmax instability against the f32 model output (see phi4mm L4
1208+
# false-failures: exact top-2 ties, top-10 spans <3 logits).
11331209
model = transformers.AutoModelForCausalLM.from_pretrained(
11341210
case.model_id,
1135-
torch_dtype=torch.bfloat16,
1211+
torch_dtype=torch.float32,
11361212
device_map=device,
11371213
trust_remote_code=True,
11381214
_attn_implementation="eager",
@@ -1284,7 +1360,7 @@ def _generate_phi4mm_multimodal(case: TestCase, json_path: Path, device: str) ->
12841360
"depth-estimation": _generate_image_classification,
12851361
"image-segmentation": _generate_image_classification,
12861362
"image-to-image": _generate_image_classification,
1287-
"object-detection": _generate_image_classification,
1363+
"object-detection": _generate_object_detection,
12881364
"phi4mm-multimodal": _generate_phi4mm_multimodal,
12891365
}
12901366

src/mobius/_configs/_base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,13 +1370,32 @@ class YolosConfig(EncoderConfig):
13701370
"""
13711371

13721372
num_detection_tokens: int = 100
1373+
# YOLOS uses a rectangular input resolution (e.g. [800, 1333] for
1374+
# yolos-tiny). The base ArchitectureConfig collapses ``image_size`` to a
1375+
# single int (height), which would size the learned position embeddings
1376+
# for a square image and mismatch the pretrained weights. Preserve both
1377+
# dimensions here so the patch grid (and position-embedding length) is
1378+
# computed correctly.
1379+
image_height: int = 800
1380+
image_width: int = 1333
13731381

13741382
@classmethod
13751383
def from_transformers(cls, config, parent_config=None) -> YolosConfig:
13761384
base = ArchitectureConfig.from_transformers(config, parent_config)
1385+
raw_image_size = getattr(config, "image_size", [base.image_size, base.image_size])
1386+
if isinstance(raw_image_size, dict):
1387+
height = int(raw_image_size.get("height", base.image_size))
1388+
width = int(raw_image_size.get("width", base.image_size))
1389+
elif isinstance(raw_image_size, (list, tuple)):
1390+
height = int(raw_image_size[0])
1391+
width = int(raw_image_size[-1])
1392+
else:
1393+
height = width = int(raw_image_size)
13771394
return cls(
13781395
**_shallow_fields(base),
13791396
num_detection_tokens=getattr(config, "num_detection_tokens", 100),
1397+
image_height=height,
1398+
image_width=width,
13801399
)
13811400

13821401

src/mobius/_testing/generation.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ def generate(
8888
shape = self.session.get_input_shape(name) or []
8989
static = [d if isinstance(d, int) and d > 0 else batch_size for d in shape]
9090
past_kv[name] = np.zeros(static, dtype=np.float32)
91+
elif ltype in ("mlp", "moe"):
92+
# Pure feed-forward layers (e.g. NemotronH hybrid) carry no
93+
# attention KV and no recurrent state — nothing to initialize.
94+
continue
9195
else:
9296
past_kv[f"past_key_values.{i}.key"] = np.zeros(
9397
(batch_size, num_kv_heads, 0, head_dim), dtype=np.float32
@@ -154,6 +158,9 @@ def generate(
154158
dst = f"past_key_values.{i}.conv_state"
155159
if src in outputs:
156160
past_kv[dst] = outputs[src]
161+
elif ltype in ("mlp", "moe"):
162+
# Pure feed-forward layers have no cache state to carry.
163+
continue
157164
else:
158165
past_kv[f"past_key_values.{i}.key"] = outputs[f"present.{i}.key"]
159166
past_kv[f"past_key_values.{i}.value"] = outputs[f"present.{i}.value"]

src/mobius/_testing/parity.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ class ParityReport:
6565
"int4": 1.0,
6666
}
6767

68+
# Minimum top-10 overlap (count of shared tokens) at/above which an argmax
69+
# mismatch is treated as a tie-break (AMBIGUOUS) rather than a divergence
70+
# (FAIL). A count of >=9 means at least 9 of the 10 highest tokens agree with
71+
# the reference. NOTE: this MUST be a count, not a Jaccard ratio: for two
72+
# size-10 sets sharing k tokens the Jaccard is k/(20-k), so 9/10 overlap is
73+
# only 0.818 (a >=0.9 ratio would require identical sets, k=10, defeating the
74+
# "9 of 10 agree" intent). The branch additionally requires the predicted
75+
# token to be within the golden top-10 AND the golden argmax to remain in the
76+
# ONNX top-2, so it only rescues genuine #1/#2 tie-break swaps (e.g. CUDA
77+
# float32 accumulation noise), not a low-ranked token promoted to #1.
78+
_AMBIGUOUS_TOP10_OVERLAP: int = 9
79+
6880
# Per-dtype default tolerances for L3 synthetic parity.
6981
DEFAULT_TOLERANCES: dict[str, tuple[float, float]] = {
7082
# (atol, rtol)
@@ -271,14 +283,15 @@ def compare_golden(
271283

272284
argmax_match = onnx_top1 == golden_top1_id
273285

274-
# Top-10 Jaccard
286+
# Top-10 Jaccard (reported) and raw overlap count (gate).
275287
onnx_top10 = set(np.argsort(onnx_last_f64)[-10:].tolist())
276288
golden_top10 = set(golden_top10_ids)
277-
jaccard = (
278-
len(onnx_top10 & golden_top10) / len(onnx_top10 | golden_top10)
279-
if golden_top10
280-
else 0.0
281-
)
289+
overlap = len(onnx_top10 & golden_top10)
290+
jaccard = overlap / len(onnx_top10 | golden_top10) if golden_top10 else 0.0
291+
# ONNX top-2 token ids, used to confirm an argmax mismatch is a #1/#2
292+
# tie-break swap (golden argmax still ranked #1 or #2 by ONNX) rather than
293+
# a low-ranked token being promoted to #1.
294+
onnx_top2_ids = set(np.argsort(onnx_last_f64)[-2:].tolist())
282295

283296
# Gate decision
284297
if argmax_match:
@@ -291,6 +304,26 @@ def compare_golden(
291304
f"but matches top2={golden_top2_id} and near-tie detected "
292305
f"(gap={abs(top1_logit - top2_logit):.4f} < margin={margin})"
293306
)
307+
elif (
308+
overlap >= _AMBIGUOUS_TOP10_OVERLAP
309+
and onnx_top1 in golden_top10
310+
and golden_top1_id in onnx_top2_ids
311+
):
312+
# >=9 of the golden top-10 tokens agree, the predicted token is itself a
313+
# golden top-10 token, AND the golden argmax is still ranked #1 or #2 by
314+
# ONNX — i.e. the argmax difference is a near-tie #1/#2 swap, not a
315+
# divergence. This catches CUDA float32 near-ties whose absolute logit
316+
# gap exceeds the per-dtype ``near_tie`` margin (CUDA accumulation noise
317+
# > CPU) yet whose ranking otherwise matches the reference. Requiring
318+
# the golden argmax to stay in the ONNX top-2 prevents masking a real
319+
# divergence where a low-ranked token is promoted to #1 with a large gap.
320+
result = ParityResult.AMBIGUOUS
321+
message = (
322+
f"L4 AMBIGUOUS: argmax={onnx_top1} != golden_top1={golden_top1_id}, "
323+
f"but {overlap}/10 of the golden top-10 agree, argmax is within the "
324+
f"golden top-10, and golden_top1 remains in the ONNX top-2 "
325+
f"— tie-break, not divergence"
326+
)
294327
else:
295328
result = ParityResult.FAIL
296329
message = (

0 commit comments

Comments
 (0)