microsoft / microsoft/onnxruntime
GemmTransposeFusion can create dimension-mismatched Gemm for identity Transpose input
- 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
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