microsoft / microsoft/onnxruntime

GemmTransposeFusion can create dimension-mismatched Gemm for identity Transpose input

Open
#32,417 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
21.9k
Forks
4.2k
Avg merge
4d 11h
Merged PRs (30d)
184

Description

### Describe the issue

# GemmTransposeFusion can turn an identity Transpose before Gemm into a dimension-mismatched Gemm

## Summary

ONNX Runtime 1.23.2 and the latest tested PyPI CPU wheel 1.29.0 accept and execute this valid model at `ORT_DISABLE_ALL`, but fail from `ORT_ENABLE_BASIC` onward after `GemmTransposeFusion` rewrites a `Gemm` whose first input is
produced by an identity `Transpose`.

## Environment

- onnxruntime: 1.23.2; reproduced again on latest tested PyPI CPU wheel 1.29.0
- onnx: 1.22.0
- provider: CPUExecutionProvider
- OS: Linux x86_64

## Reproducer

Minimal graph:

```text
x[3,4] -> Transpose(perm=[0,1]) -> xt[3,4]
xt, W[4,5], b[5] -> Gemm -> y[3,5]
```

Run:

```bash
python repro.py
```

Observed result:

```text
ORT_DISABLE_ALL: ok
ORT_ENABLE_BASIC: GEMM: Dimension mismatch, W: {4,5} K: 3 N:5
ORT_ENABLE_EXTENDED: same error
ORT_ENABLE_ALL: same error
```

Basic ablation in the reproducer:

```text
disabled_optimizers=["GemmTransposeFusion"]: ok
disabled_optimizers=["TransposeOptimizer"]: same error
disabled_optimizers=["GemmActivationFusion"]: same error
disabled_optimizers=["Level1_RuleBasedTransformer"]: ok
```

## Why this looks like an optimizer interaction

The `Transpose` has identity permutation `[0,1]`, so the graph is semantically equivalent to `Gemm(x, W, b)` and executes correctly without optimization.

After optimization, the failing `Gemm` is named with `/GemmTransposeFusion/`, and the optimized node has `transA=1` even though the removed `Transpose` had identity permutation `[0,1]`. Disabling `GemmTransposeFusion` alone
restores the correct output. This suggests that the fusion handles a preceding `Transpose` as if it were a real rank-2 transpose instead of verifying that the permutation actually swaps the matrix axes.

The same pattern was also inserted as a zeroed donor branch into real seed models. A small campaign produced 22/22 failures across MobileNetV2, DeiT-tiny, ResNet50, and ConvNeXt-tiny, all with the same `GemmTransposeFusion`
dimension-mismatch root.

Additional probe evidence from `tools/probe_gemmtranspose_matrix.py`:

- A-input identity transpose with non-square input fails by dimension mismatch.
- A-input identity transpose with square input executes but produces a different numeric result after optimization.
- Real `Transpose(perm=[1,0])` A-input cases are handled correctly.

The square wrong-result case is also available as a self-checking local suite:

```text
repros/gemmtranspose_identity_wrong_result_suite/repro.py
case: gemm_a_identity_transpose_square_wrong_result
ORT_ENABLE_BASIC max_abs_diff_vs_disable: 4.9599997997283936
disabled_optimizers=["GemmTransposeFusion"]: max_abs_diff_vs_disable 0.0
```

So this is not a generic Gemm transpose fusion failure; it is specifically the identity-transpose case being treated as if it swapped the matrix axes.

### To reproduce

## Full minimal reproducer

Save as `repro.py` and run with `python repro.py`:

```python
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from collections import Counter
from pathlib import Path

import numpy as np
import onnx
import onnxruntime as ort
from onnx import TensorProto, helper, numpy_helper

def _const(name: str, value: np.ndarray) -> onnx.TensorProto:
return numpy_helper.from_array(value.astype(np.float32), name=name)

def build_model(path: Path) -> None:
x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [3, 4])
y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [3, 5])
w = _const("w", np.arange(20, dtype=np.float32).reshape(4, 5) / 20.0)
b = _const("b", np.linspace(-0.2, 0.2, num=5, dtype=np.float32))
nodes = [
helper.make_node("Transpose", ["x"], ["xt"], name="identity_transpose", perm=[0, 1]),
helper.make_node("Gemm", ["xt", "w", "b"], ["y"], name="gemm"),
]
graph = helper.make_graph(nodes, "transpose_identity_gemm_basic_dimension_mismatch", [x], [y], initializer=[w, b])
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
model.ir_version = 8
onnx.checker.check_model(model)
onnx.save_model(model, str(path))

def graph_summary(path: Path) -> dict | None:
if not path.exists():
return None
model = onnx.load(str(path), load_external_data=True)
ops = Counter(f"{node.domain or 'ai.onnx'}::{node.op_type}" for node in model.graph.node)
node_attrs = []
for node in model.graph.node:
attrs = {attr.name: helper.get_attribute_value(attr) for attr in node.attribute}
node_attrs.append({"name": node.name, "op_type": node.op_type, "inputs": list(node.input), "outputs": list(node.output), "attrs": attrs})
return {"nodes": len(model.graph.node), "ops": dict(sorted(ops.items())), "node_attrs": node_attrs}

def run(model_path: Path, level: str, out_dir: Path, disabled: list[str] | None = None) -> dict:
levels = {
"disable": ort.GraphOptimizationLevel.ORT_DISABLE_ALL,
"basic": ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
"extended": ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED,
"all": ort.GraphOptimizationLevel.ORT_ENABLE_ALL,
}
out_dir.mkdir(parents=True, exist_ok=True)
opt_path = out_dir / f"optimized_{level}_{'_'.join(disabled or ['full'])}.onnx"
so = ort.SessionOptions()
so.graph_optimization_level = levels[level]
so.optimized_model_filepath = str(opt_path)
feeds = {"x": np.linspace(-1.0, 1.0, num=12, dtype=np.float32).reshape(3, 4)}
kwargs = {"sess_options": so, "providers": ["CPUExecutionProvider"]}
if disabled:
kwargs["disabled_optimizers"] = disabled
try:
sess = ort.InferenceSession(str(model_path), **kwargs)
out = sess.run(None, feeds)[0]
return {
"level": level,
"disabled": disabled or [],
"status": "ok",
"shape": list(out.shape),
"sum": float(out.sum()),
"optimized_graph": graph_summary(opt_path),
}
except Exception as exc:
return {
"level": level,
"disabled": disabled or [],
"status": "error",
"error": f"{type(exc).__name__}: {exc}",
"optimized_graph": graph_summary(opt_path),
}

def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="model.onnx")
parser.add_argument("--out", default="result.json")
parser.add_argument("--optimized-dir", default="optimized")
args = parser.parse_args()

model_path = Path(args.model)
out_dir = Path(args.optimized_dir)
build_model(model_path)
payload = {
"onnxruntime": ort.__version__,
"providers": ort.get_available_providers(),
"runs": [run(model_path, level, out_dir) for level in ("disable", "basic", "extended", "all")],
"ablation": [
run(model_path, "basic", out_dir, [name])
for name in (
"GemmTransposeFusion",
"TransposeOptimizer",
"GemmActivationFusion",
"Level1_RuleBasedTransformer",
)
],
}
Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(json.dumps(payload, indent=2))

if __name__ == "__main__":
main()
```

### Urgency

_No response_

### Platform

Linux

### OS Version

ubuntu22.04

### ONNX Runtime Installation

Released Package

### ONNX Runtime Version or Commit ID

1.29.0

### ONNX Runtime API

Python

### Architecture

X64

### Execution Provider

Default CPU

### Execution Provider Library Version

_No response_

Contributor guide

Open the contributing guide

Research direction

Run repro.py with the stated ONNX Runtime optimization levels and compare the optimized graphs, then use tools/probe_gemmtranspose_matrix.py to check identity and real transpose cases. Review repros/gemmtranspose_identity_wrong_result_suite/repro.py for the square wrong-result case. Done means identity Transpose inputs preserve Gemm dimensions and values, while real transposes remain correctly handled, with regression coverage for the reported cases.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, numpy, python
Domain
machine-learning, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.