[GlobalOpt] Add pass to convert broadcast batch_matmul to matmul (#24670)
When a `linalg.batch_matmul` multiplies a batched input by a weight that
is the same for every batch, every batch slice runs the same matmul
against the same weight. This pass rewrites that case into a single
`linalg.matmul`.
For an input of shape `[B, M, K]` and a weight `[K, N]` broadcast to
`[B, K, N]`, the pass:
1. collapses the input to `[B*M, K]` using tensor.collapse_shape
2. multiplies it by the `[K, N]` weight using linalg.matmul
3. expands the result back to `[B, M, N]` using tensor.expand_shape
This removes the broadcast and turns the batched contraction into a
plain
matmul, which downstream passes can handle better.
---------
Signed-off-by: Roberto Laudani <laudani@roofline.ai>
Co-authored-by: ziereis <ziereis@roofline.ai>
diff --git a/compiler/src/iree/compiler/GlobalOptimization/BUILD.bazel b/compiler/src/iree/compiler/GlobalOptimization/BUILD.bazel
index 03dd920..5b5ac17 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/BUILD.bazel
+++ b/compiler/src/iree/compiler/GlobalOptimization/BUILD.bazel
@@ -50,6 +50,7 @@
name = "GlobalOptimization",
srcs = [
"CleanupNumericNarrowing.cpp",
+ "ConvertBatchMatmulToMatmul.cpp",
"ConvertConv2DToImg2Col.cpp",
"ConvertStridedContractionToContraction.cpp",
"DataLayoutPropagation.cpp",
diff --git a/compiler/src/iree/compiler/GlobalOptimization/CMakeLists.txt b/compiler/src/iree/compiler/GlobalOptimization/CMakeLists.txt
index 050b3c9..c3b994d 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/CMakeLists.txt
+++ b/compiler/src/iree/compiler/GlobalOptimization/CMakeLists.txt
@@ -41,6 +41,7 @@
"Utils.h"
SRCS
"CleanupNumericNarrowing.cpp"
+ "ConvertBatchMatmulToMatmul.cpp"
"ConvertConv2DToImg2Col.cpp"
"ConvertStridedContractionToContraction.cpp"
"DataLayoutPropagation.cpp"
diff --git a/compiler/src/iree/compiler/GlobalOptimization/ConvertBatchMatmulToMatmul.cpp b/compiler/src/iree/compiler/GlobalOptimization/ConvertBatchMatmulToMatmul.cpp
new file mode 100644
index 0000000..648edf1
--- /dev/null
+++ b/compiler/src/iree/compiler/GlobalOptimization/ConvertBatchMatmulToMatmul.cpp
@@ -0,0 +1,247 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/compiler/GlobalOptimization/Passes.h"
+#include "mlir/Dialect/Arith/IR/Arith.h"
+#include "mlir/Dialect/Linalg/IR/Linalg.h"
+#include "mlir/Dialect/Tensor/IR/Tensor.h"
+#include "mlir/IR/AffineExpr.h"
+#include "mlir/IR/AffineMap.h"
+#include "mlir/IR/PatternMatch.h"
+#include "mlir/Transforms/GreedyPatternRewriteDriver.h"
+
+namespace mlir::iree_compiler::GlobalOptimization {
+
+#define GEN_PASS_DEF_CONVERTBATCHMATMULTOMATMULPASS
+#include "iree/compiler/GlobalOptimization/Passes.h.inc"
+
+namespace {
+
+/// If `op` broadcasts a weight along the batch dimension (dim 0) only, returns
+/// the original pre-broadcast weight; otherwise returns failure. Handles both
+/// linalg.broadcast and its generic form (after generalization).
+static FailureOr<Value> getOriginalBroadcastWeight(Operation *op) {
+ if (auto broadcastOp = dyn_cast<linalg::BroadcastOp>(op)) {
+ ArrayRef<int64_t> dims = broadcastOp.getDimensions();
+ if (dims.size() != 1 || dims[0] != 0) {
+ return failure();
+ }
+ return broadcastOp.getInput();
+ }
+
+ auto genericOp = dyn_cast<linalg::GenericOp>(op);
+ if (!genericOp) {
+ return failure();
+ }
+
+ // Check generic is a broadcast: 1 input, 1 output, all parallel iterators
+ if (genericOp.getNumDpsInputs() != 1 || genericOp.getNumDpsInits() != 1) {
+ return failure();
+ }
+
+ auto iterTypes = genericOp.getIteratorTypesArray();
+ if (!llvm::all_of(iterTypes, [](utils::IteratorType t) {
+ return t == utils::IteratorType::parallel;
+ })) {
+ return failure();
+ }
+
+ SmallVector<AffineMap> maps = genericOp.getIndexingMapsArray();
+ AffineMap inputMap = maps[0];
+ AffineMap outputMap = maps[1];
+
+ unsigned numDims = outputMap.getNumDims();
+ if (!outputMap.isIdentity()) {
+ return failure();
+ }
+
+ if (!inputMap.isProjectedPermutation()) {
+ return failure();
+ }
+
+ // Check that the input map is exactly (d0, d1, ..., dN) -> (d1, d2, ..., dN)
+ // i.e., only dimension 0 is broadcast (missing in the result affine expr) and
+ // the remaining dimensions are in order (not permuted). A permuted map like
+ // (d0,d1,d2) -> (d2,d1) represents a fused broadcast+transpose, which this
+ // pattern cannot handle.
+ if (inputMap.getNumResults() != numDims - 1) {
+ return failure();
+ }
+ for (unsigned i = 0; i < inputMap.getNumResults(); ++i) {
+ auto dimExpr = dyn_cast<AffineDimExpr>(inputMap.getResult(i));
+ if (!dimExpr || dimExpr.getPosition() != i + 1) {
+ return failure();
+ }
+ }
+
+ // Check body simply yields the input argument
+ Block *body = genericOp.getBody();
+ if (body->getNumArguments() != 2) {
+ return failure();
+ }
+ auto yieldOp = dyn_cast<linalg::YieldOp>(body->getTerminator());
+ if (!yieldOp || yieldOp.getNumOperands() != 1) {
+ return failure();
+ }
+ if (yieldOp.getOperand(0) != body->getArgument(0)) {
+ return failure();
+ }
+
+ return genericOp.getDpsInputs()[0];
+}
+
+static int64_t computeCollapsedDim(int64_t dim0, int64_t dim1) {
+ if (dim0 == ShapedType::kDynamic || dim1 == ShapedType::kDynamic) {
+ return ShapedType::kDynamic;
+ }
+ return dim0 * dim1;
+}
+
+static Value createCollapseShape(OpBuilder &builder, Location loc, Value input,
+ RankedTensorType inputType) {
+ SmallVector<ReassociationIndices> reassoc = {{0, 1}, {2}};
+ int64_t collapsedDim0 =
+ computeCollapsedDim(inputType.getDimSize(0), inputType.getDimSize(1));
+ auto collapsedType = RankedTensorType::get(
+ {collapsedDim0, inputType.getDimSize(2)}, inputType.getElementType());
+ return tensor::CollapseShapeOp::create(builder, loc, collapsedType, input,
+ reassoc);
+}
+
+static Value createExpandShape(OpBuilder &builder, Location loc, Value input,
+ RankedTensorType outputType, Value originalAct,
+ Value originalOut) {
+ SmallVector<ReassociationIndices> reassoc = {{0, 1}, {2}};
+
+ // Build output shape - extract dynamic dimensions from original tensors
+ SmallVector<OpFoldResult> outputShape;
+ for (int64_t i = 0; i < 3; ++i) {
+ if (outputType.isDynamicDim(i)) {
+ // For batch (i=0) and M (i=1), get from original activation
+ // For N (i=2), get from original output
+ Value source = (i < 2) ? originalAct : originalOut;
+ Value idx = arith::ConstantIndexOp::create(builder, loc, i);
+ Value dimVal = tensor::DimOp::create(builder, loc, source, idx);
+ outputShape.push_back(dimVal);
+ } else {
+ outputShape.push_back(builder.getIndexAttr(outputType.getDimSize(i)));
+ }
+ }
+
+ return tensor::ExpandShapeOp::create(builder, loc, outputType, input, reassoc,
+ outputShape);
+}
+
+/// Pattern to convert broadcast + batch_matmul to collapse_shape + matmul +
+/// expand_shape. This handles BatchMatmulOp and BatchMatmulTransposeBOp
+/// (which share the same underlying op but with different indexing maps).
+/// BatchMatmulTransposeAOp is not supported because the collapse would
+/// produce mismatched shapes (LHS collapses batch*K but output collapses
+/// batch*M).
+class ConvertBroadcastBatchMatmulToMatmul
+ : public OpRewritePattern<linalg::BatchMatmulOp> {
+public:
+ using OpRewritePattern<linalg::BatchMatmulOp>::OpRewritePattern;
+
+ LogicalResult matchAndRewrite(linalg::BatchMatmulOp batchMatmul,
+ PatternRewriter &rewriter) const override {
+ // The RHS must be a batch-dim broadcast of a 2-D weight.
+ Value rhs = batchMatmul.getDpsInputOperand(1)->get();
+ Operation *rhsDefOp = rhs.getDefiningOp();
+ if (!rhsDefOp) {
+ return failure();
+ }
+ FailureOr<Value> weight = getOriginalBroadcastWeight(rhsDefOp);
+ if (failed(weight)) {
+ return failure();
+ }
+
+ // Get LHS activation
+ Value act = batchMatmul.getDpsInputOperand(0)->get();
+ auto actType = cast<RankedTensorType>(act.getType());
+
+ Value out = batchMatmul.getDpsInitOperand(0)->get();
+ auto outType = cast<RankedTensorType>(out.getType());
+
+ // Classify the contraction layout by comparing the indexing maps directly.
+ // batch_matmul has four loop dimensions (batch, m, n, k); the canonical
+ // maps are LHS (batch, m, k), RHS (batch, k, n) and OUT (batch, m, n).
+ MLIRContext *ctx = batchMatmul.getContext();
+ AffineExpr bDim, mDim, nDim, kDim;
+ bindDims(ctx, bDim, mDim, nDim, kDim);
+ auto mapOf = [&](ArrayRef<AffineExpr> results) {
+ return AffineMap::get(/*dimCount=*/4, /*symbolCount=*/0, results, ctx);
+ };
+ SmallVector<AffineMap> maps = batchMatmul.getIndexingMapsArray();
+ if (maps.size() != 3) {
+ return failure();
+ }
+ // Only fold when the LHS is [batch, M, K] and the output is [batch, M, N].
+ // A transposed LHS [batch, K, M] cannot be folded: collapsing it merges the
+ // batch and leading dims into [batch*K, M], which no longer matches the
+ // output's [batch*M, N] collapse.
+ if (maps[0] != mapOf({bDim, mDim, kDim}) ||
+ maps[2] != mapOf({bDim, mDim, nDim})) {
+ return failure();
+ }
+ // The RHS may be plain (batch, k, n) or transpose_b (batch, n, k).
+ bool transposeB;
+ if (maps[1] == mapOf({bDim, kDim, nDim})) {
+ transposeB = false;
+ } else if (maps[1] == mapOf({bDim, nDim, kDim})) {
+ transposeB = true;
+ } else {
+ return failure();
+ }
+
+ Location loc = batchMatmul.getLoc();
+
+ // Collapse activation: [batch, dim1, dim2] -> [batch*dim1, dim2]
+ Value collapsedAct = createCollapseShape(rewriter, loc, act, actType);
+
+ // Collapse output init tensor
+ Value collapsedOut = createCollapseShape(rewriter, loc, out, outType);
+
+ // Create matmul - check if this is a transpose variant
+ auto collapsedOutType = cast<RankedTensorType>(collapsedOut.getType());
+ Value matmulResult;
+
+ if (transposeB) {
+ matmulResult = linalg::MatmulTransposeBOp::create(
+ rewriter, loc, collapsedOutType,
+ ValueRange{collapsedAct, *weight}, collapsedOut)
+ .getResult(0);
+ } else {
+ matmulResult = linalg::MatmulOp::create(rewriter, loc, collapsedOutType,
+ ValueRange{collapsedAct, *weight},
+ collapsedOut)
+ .getResult(0);
+ }
+
+ // Expand result back to 3D: [batch*dim1, dim2] -> [batch, dim1, dim2]
+ Value expandedResult =
+ createExpandShape(rewriter, loc, matmulResult, outType, act, out);
+
+ rewriter.replaceOp(batchMatmul, expandedResult);
+ return success();
+ }
+};
+
+struct ConvertBatchMatmulToMatmulPass
+ : public impl::ConvertBatchMatmulToMatmulPassBase<
+ ConvertBatchMatmulToMatmulPass> {
+ void runOnOperation() override {
+ MLIRContext *context = &getContext();
+ RewritePatternSet patterns(context);
+ patterns.add<ConvertBroadcastBatchMatmulToMatmul>(context);
+ if (failed(applyPatternsGreedily(getOperation(), std::move(patterns)))) {
+ return signalPassFailure();
+ }
+ }
+};
+
+} // namespace
+} // namespace mlir::iree_compiler::GlobalOptimization
diff --git a/compiler/src/iree/compiler/GlobalOptimization/Passes.cpp b/compiler/src/iree/compiler/GlobalOptimization/Passes.cpp
index 2db2ee1..049053d 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/Passes.cpp
+++ b/compiler/src/iree/compiler/GlobalOptimization/Passes.cpp
@@ -173,6 +173,12 @@
createFuseDequantizationMatmulPass)
.addPass(IREE::Flow::createCanonicalizePass)
.addPass(mlir::createCSEPass)
+ // Convert broadcast + batch_matmul before transpose propagation so we
+ // can match the pure broadcast patterns before they get fused with
+ // transposes.
+ .addPass(GlobalOptimization::createConvertBatchMatmulToMatmulPass);
+
+ FunctionLikeNest(mainPassManager)
// Propagate transposes immediately before set encoding/data tiling
// because transpose propagation cannot take an opinion on the preferred
// layout of various operations. This simplifies local propagation
diff --git a/compiler/src/iree/compiler/GlobalOptimization/Passes.td b/compiler/src/iree/compiler/GlobalOptimization/Passes.td
index 8b9260e..b05e76d 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/Passes.td
+++ b/compiler/src/iree/compiler/GlobalOptimization/Passes.td
@@ -14,6 +14,16 @@
let summary = "Cleans up any numeric narrowing ops inserted by iree-global-opt-infer-numeric-narrowing.";
}
+def ConvertBatchMatmulToMatmulPass :
+ Pass<"iree-global-opt-convert-batch-matmul-to-matmul", ""> {
+ let summary = "Converts batch_matmul to reshape + matmul + reshape.";
+ let dependentDialects = [
+ "mlir::arith::ArithDialect",
+ "mlir::linalg::LinalgDialect",
+ "mlir::tensor::TensorDialect",
+ ];
+}
+
def ConvertConv2DToImg2ColPass :
Pass<"iree-global-opt-convert-conv2d-to-img2col", ""> {
let summary = "Convert linalg convolution ops to matmul img2col based implementation";
diff --git a/compiler/src/iree/compiler/GlobalOptimization/test/BUILD.bazel b/compiler/src/iree/compiler/GlobalOptimization/test/BUILD.bazel
index 8d33426..a8481d7 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/test/BUILD.bazel
+++ b/compiler/src/iree/compiler/GlobalOptimization/test/BUILD.bazel
@@ -19,6 +19,7 @@
[
"cleanup_numeric_narrowing.mlir",
"conv2d_to_img2col.mlir",
+ "convert_batch_matmul_to_matmul.mlir",
"data_layout_propagation.mlir",
"demote_contraction_inputs.mlir",
"detach_elementwise_from_named_ops.mlir",
diff --git a/compiler/src/iree/compiler/GlobalOptimization/test/CMakeLists.txt b/compiler/src/iree/compiler/GlobalOptimization/test/CMakeLists.txt
index e36a746..95ae4f9 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/test/CMakeLists.txt
+++ b/compiler/src/iree/compiler/GlobalOptimization/test/CMakeLists.txt
@@ -16,6 +16,7 @@
SRCS
"cleanup_numeric_narrowing.mlir"
"conv2d_to_img2col.mlir"
+ "convert_batch_matmul_to_matmul.mlir"
"data_layout_propagation.mlir"
"demote_contraction_inputs.mlir"
"detach_elementwise_from_named_ops.mlir"
diff --git a/compiler/src/iree/compiler/GlobalOptimization/test/convert_batch_matmul_to_matmul.mlir b/compiler/src/iree/compiler/GlobalOptimization/test/convert_batch_matmul_to_matmul.mlir
new file mode 100644
index 0000000..1d36e5e
--- /dev/null
+++ b/compiler/src/iree/compiler/GlobalOptimization/test/convert_batch_matmul_to_matmul.mlir
@@ -0,0 +1,290 @@
+// RUN: iree-opt --split-input-file --mlir-print-local-scope -iree-global-opt-convert-batch-matmul-to-matmul %s | FileCheck %s
+
+// Static shapes with named broadcast
+// CHECK-LABEL: util.func public @static_broadcast_batch_matmul
+// CHECK-SAME: %[[ARG0:[a-zA-Z0-9]+]]: tensor<4x10x10xf32>, %[[ARG1:[a-zA-Z0-9]+]]: tensor<10x10xf32>
+// CHECK-DAG: %[[CST:.*]] = arith.constant 0.000000e+00 : f32
+// CHECK: %[[EMPTY:.*]] = tensor.empty() : tensor<4x10x10xf32>
+// CHECK: %[[FILL:.*]] = linalg.fill ins(%[[CST]] : f32) outs(%[[EMPTY]] : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+// CHECK: %[[COLLAPSED_ACT:.*]] = tensor.collapse_shape %[[ARG0]] {{\[}}[0, 1], [2]{{\]}} : tensor<4x10x10xf32> into tensor<40x10xf32>
+// CHECK: %[[COLLAPSED_OUT:.*]] = tensor.collapse_shape %[[FILL]] {{\[}}[0, 1], [2]{{\]}} : tensor<4x10x10xf32> into tensor<40x10xf32>
+// CHECK: %[[MATMUL:.*]] = linalg.matmul ins(%[[COLLAPSED_ACT]], %[[ARG1]] : tensor<40x10xf32>, tensor<10x10xf32>) outs(%[[COLLAPSED_OUT]] : tensor<40x10xf32>) -> tensor<40x10xf32>
+// CHECK: %[[EXPANDED:.*]] = tensor.expand_shape %[[MATMUL]] {{\[}}[0, 1], [2]{{\]}} output_shape [4, 10, 10] : tensor<40x10xf32> into tensor<4x10x10xf32>
+// CHECK: util.return %[[EXPANDED]] : tensor<4x10x10xf32>
+util.func public @static_broadcast_batch_matmul(%act: tensor<4x10x10xf32>, %weight: tensor<10x10xf32>) -> tensor<4x10x10xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x10x10xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<10x10xf32>)
+ outs(%init_broadcast : tensor<4x10x10xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<4x10x10xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+ %result = linalg.batch_matmul ins(%act, %broadcast : tensor<4x10x10xf32>, tensor<4x10x10xf32>)
+ outs(%fill : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+ util.return %result : tensor<4x10x10xf32>
+}
+
+// -----
+
+// Dynamic batch dimension (M is static)
+// CHECK-LABEL: util.func public @dynamic_batch_broadcast_batch_matmul
+// CHECK-SAME: %[[ARG0:[a-zA-Z0-9]+]]: tensor<?x10x10xf32>, %[[ARG1:[a-zA-Z0-9]+]]: tensor<10x10xf32>
+// CHECK-DAG: %[[CST:.*]] = arith.constant 0.000000e+00 : f32
+// CHECK-DAG: %[[C0:.*]] = arith.constant 0 : index
+// CHECK: %[[DIM:.*]] = tensor.dim %[[ARG0]], %[[C0]] : tensor<?x10x10xf32>
+// CHECK: %[[EMPTY:.*]] = tensor.empty(%[[DIM]]) : tensor<?x10x10xf32>
+// CHECK: %[[FILL:.*]] = linalg.fill ins(%[[CST]] : f32) outs(%[[EMPTY]] : tensor<?x10x10xf32>) -> tensor<?x10x10xf32>
+// CHECK: %[[COLLAPSED_ACT:.*]] = tensor.collapse_shape %[[ARG0]] {{\[}}[0, 1], [2]{{\]}} : tensor<?x10x10xf32> into tensor<?x10xf32>
+// CHECK: %[[COLLAPSED_OUT:.*]] = tensor.collapse_shape %[[FILL]] {{\[}}[0, 1], [2]{{\]}} : tensor<?x10x10xf32> into tensor<?x10xf32>
+// CHECK: %[[MATMUL:.*]] = linalg.matmul ins(%[[COLLAPSED_ACT]], %[[ARG1]] : tensor<?x10xf32>, tensor<10x10xf32>) outs(%[[COLLAPSED_OUT]] : tensor<?x10xf32>) -> tensor<?x10xf32>
+// CHECK: %[[BATCH:.*]] = tensor.dim %[[ARG0]], %[[C0]] : tensor<?x10x10xf32>
+// CHECK: %[[EXPANDED:.*]] = tensor.expand_shape %[[MATMUL]] {{\[}}[0, 1], [2]{{\]}} output_shape [%[[BATCH]], 10, 10] : tensor<?x10xf32> into tensor<?x10x10xf32>
+// CHECK: util.return %[[EXPANDED]] : tensor<?x10x10xf32>
+util.func public @dynamic_batch_broadcast_batch_matmul(%act: tensor<?x10x10xf32>, %weight: tensor<10x10xf32>) -> tensor<?x10x10xf32> {
+ %c0 = arith.constant 0 : index
+ %batch = tensor.dim %act, %c0 : tensor<?x10x10xf32>
+ %init_broadcast = tensor.empty(%batch) : tensor<?x10x10xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<10x10xf32>)
+ outs(%init_broadcast : tensor<?x10x10xf32>) dimensions = [0]
+ %init_out = tensor.empty(%batch) : tensor<?x10x10xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<?x10x10xf32>) -> tensor<?x10x10xf32>
+ %result = linalg.batch_matmul ins(%act, %broadcast : tensor<?x10x10xf32>, tensor<?x10x10xf32>)
+ outs(%fill : tensor<?x10x10xf32>) -> tensor<?x10x10xf32>
+ util.return %result : tensor<?x10x10xf32>
+}
+
+// -----
+
+// Generic broadcast form (after generalization)
+// CHECK-LABEL: util.func public @generic_broadcast_batch_matmul
+// CHECK-SAME: %[[ARG0:[a-zA-Z0-9]+]]: tensor<?x10x10xf32>, %[[ARG1:[a-zA-Z0-9]+]]: tensor<10x10xf32>
+// CHECK-DAG: %[[CST:.*]] = arith.constant 0.000000e+00 : f32
+// CHECK-DAG: %[[C0:.*]] = arith.constant 0 : index
+// CHECK: %[[DIM:.*]] = tensor.dim %[[ARG0]], %[[C0]] : tensor<?x10x10xf32>
+// CHECK: %[[EMPTY:.*]] = tensor.empty(%[[DIM]]) : tensor<?x10x10xf32>
+// CHECK: %[[FILL:.*]] = linalg.fill ins(%[[CST]] : f32) outs(%[[EMPTY]] : tensor<?x10x10xf32>) -> tensor<?x10x10xf32>
+// CHECK: %[[COLLAPSED_ACT:.*]] = tensor.collapse_shape %[[ARG0]] {{\[}}[0, 1], [2]{{\]}} : tensor<?x10x10xf32> into tensor<?x10xf32>
+// CHECK: %[[COLLAPSED_OUT:.*]] = tensor.collapse_shape %[[FILL]] {{\[}}[0, 1], [2]{{\]}} : tensor<?x10x10xf32> into tensor<?x10xf32>
+// CHECK: %[[MATMUL:.*]] = linalg.matmul ins(%[[COLLAPSED_ACT]], %[[ARG1]] : tensor<?x10xf32>, tensor<10x10xf32>) outs(%[[COLLAPSED_OUT]] : tensor<?x10xf32>) -> tensor<?x10xf32>
+// CHECK: %[[BATCH:.*]] = tensor.dim %[[ARG0]], %[[C0]] : tensor<?x10x10xf32>
+// CHECK: %[[EXPANDED:.*]] = tensor.expand_shape %[[MATMUL]] {{\[}}[0, 1], [2]{{\]}} output_shape [%[[BATCH]], 10, 10] : tensor<?x10xf32> into tensor<?x10x10xf32>
+// CHECK: util.return %[[EXPANDED]] : tensor<?x10x10xf32>
+util.func public @generic_broadcast_batch_matmul(%act: tensor<?x10x10xf32>, %weight: tensor<10x10xf32>) -> tensor<?x10x10xf32> {
+ %c0 = arith.constant 0 : index
+ %batch = tensor.dim %act, %c0 : tensor<?x10x10xf32>
+ %init_broadcast = tensor.empty(%batch) : tensor<?x10x10xf32>
+ // Generic form of broadcast (as produced by GeneralizeLinalgNamedOps)
+ %broadcast = linalg.generic {
+ indexing_maps = [affine_map<(d0, d1, d2) -> (d1, d2)>,
+ affine_map<(d0, d1, d2) -> (d0, d1, d2)>],
+ iterator_types = ["parallel", "parallel", "parallel"]
+ } ins(%weight : tensor<10x10xf32>) outs(%init_broadcast : tensor<?x10x10xf32>) {
+ ^bb0(%in: f32, %out: f32):
+ linalg.yield %in : f32
+ } -> tensor<?x10x10xf32>
+ %init_out = tensor.empty(%batch) : tensor<?x10x10xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<?x10x10xf32>) -> tensor<?x10x10xf32>
+ %result = linalg.batch_matmul ins(%act, %broadcast : tensor<?x10x10xf32>, tensor<?x10x10xf32>)
+ outs(%fill : tensor<?x10x10xf32>) -> tensor<?x10x10xf32>
+ util.return %result : tensor<?x10x10xf32>
+}
+
+// -----
+
+// Negative test - broadcast on LHS (should NOT transform)
+// CHECK-LABEL: util.func public @broadcast_on_lhs
+// CHECK: linalg.broadcast
+// CHECK: linalg.batch_matmul
+// CHECK-NOT: tensor.collapse_shape
+util.func public @broadcast_on_lhs(%lhs: tensor<10x10xf32>, %rhs: tensor<4x10x10xf32>) -> tensor<4x10x10xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x10x10xf32>
+ %broadcast = linalg.broadcast ins(%lhs : tensor<10x10xf32>)
+ outs(%init_broadcast : tensor<4x10x10xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<4x10x10xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+ %result = linalg.batch_matmul ins(%broadcast, %rhs : tensor<4x10x10xf32>, tensor<4x10x10xf32>)
+ outs(%fill : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+ util.return %result : tensor<4x10x10xf32>
+}
+
+// -----
+
+// Both batch and M are dynamic (should transform with explicit output_shape)
+// CHECK-LABEL: util.func public @both_batch_and_m_dynamic
+// CHECK-SAME: %[[ARG0:[a-zA-Z0-9]+]]: tensor<?x?x10xf32>, %[[ARG1:[a-zA-Z0-9]+]]: tensor<10x10xf32>
+// CHECK-DAG: %[[CST:.*]] = arith.constant 0.000000e+00 : f32
+// CHECK-DAG: %[[C0:.*]] = arith.constant 0 : index
+// CHECK-DAG: %[[C1:.*]] = arith.constant 1 : index
+// CHECK: %[[BATCH:.*]] = tensor.dim %[[ARG0]], %[[C0]] : tensor<?x?x10xf32>
+// CHECK: %[[M:.*]] = tensor.dim %[[ARG0]], %[[C1]] : tensor<?x?x10xf32>
+// CHECK: %[[EMPTY:.*]] = tensor.empty(%[[BATCH]], %[[M]]) : tensor<?x?x10xf32>
+// CHECK: %[[FILL:.*]] = linalg.fill ins(%[[CST]] : f32) outs(%[[EMPTY]] : tensor<?x?x10xf32>) -> tensor<?x?x10xf32>
+// CHECK: %[[COLLAPSED_ACT:.*]] = tensor.collapse_shape %[[ARG0]] {{\[}}[0, 1], [2]{{\]}} : tensor<?x?x10xf32> into tensor<?x10xf32>
+// CHECK: %[[COLLAPSED_OUT:.*]] = tensor.collapse_shape %[[FILL]] {{\[}}[0, 1], [2]{{\]}} : tensor<?x?x10xf32> into tensor<?x10xf32>
+// CHECK: %[[MATMUL:.*]] = linalg.matmul ins(%[[COLLAPSED_ACT]], %[[ARG1]] : tensor<?x10xf32>, tensor<10x10xf32>) outs(%[[COLLAPSED_OUT]] : tensor<?x10xf32>) -> tensor<?x10xf32>
+// CHECK: %[[DIM_BATCH:.*]] = tensor.dim %[[ARG0]], %[[C0]] : tensor<?x?x10xf32>
+// CHECK: %[[DIM_M:.*]] = tensor.dim %[[ARG0]], %[[C1]] : tensor<?x?x10xf32>
+// CHECK: %[[EXPANDED:.*]] = tensor.expand_shape %[[MATMUL]] {{\[}}[0, 1], [2]{{\]}} output_shape [%[[DIM_BATCH]], %[[DIM_M]], 10] : tensor<?x10xf32> into tensor<?x?x10xf32>
+// CHECK: util.return %[[EXPANDED]] : tensor<?x?x10xf32>
+util.func public @both_batch_and_m_dynamic(%act: tensor<?x?x10xf32>, %weight: tensor<10x10xf32>) -> tensor<?x?x10xf32> {
+ %c0 = arith.constant 0 : index
+ %c1 = arith.constant 1 : index
+ %batch = tensor.dim %act, %c0 : tensor<?x?x10xf32>
+ %m = tensor.dim %act, %c1 : tensor<?x?x10xf32>
+ %init_broadcast = tensor.empty(%batch) : tensor<?x10x10xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<10x10xf32>)
+ outs(%init_broadcast : tensor<?x10x10xf32>) dimensions = [0]
+ %init_out = tensor.empty(%batch, %m) : tensor<?x?x10xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<?x?x10xf32>) -> tensor<?x?x10xf32>
+ %result = linalg.batch_matmul ins(%act, %broadcast : tensor<?x?x10xf32>, tensor<?x10x10xf32>)
+ outs(%fill : tensor<?x?x10xf32>) -> tensor<?x?x10xf32>
+ util.return %result : tensor<?x?x10xf32>
+}
+
+// -----
+
+// Negative test - broadcast adds dimension other than batch (should NOT transform)
+// CHECK-LABEL: util.func public @broadcast_non_batch_dim
+// CHECK: linalg.broadcast
+// CHECK: linalg.batch_matmul
+// CHECK-NOT: tensor.collapse_shape
+util.func public @broadcast_non_batch_dim(%act: tensor<4x10x10xf32>, %weight: tensor<4x10xf32>) -> tensor<4x10x10xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x10x10xf32>
+ // Broadcast adds dimension 1, not 0
+ %broadcast = linalg.broadcast ins(%weight : tensor<4x10xf32>)
+ outs(%init_broadcast : tensor<4x10x10xf32>) dimensions = [1]
+ %init_out = tensor.empty() : tensor<4x10x10xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+ %result = linalg.batch_matmul ins(%act, %broadcast : tensor<4x10x10xf32>, tensor<4x10x10xf32>)
+ outs(%fill : tensor<4x10x10xf32>) -> tensor<4x10x10xf32>
+ util.return %result : tensor<4x10x10xf32>
+}
+
+// -----
+
+// Transpose B variant (RHS weight is [N, K] instead of [K, N])
+// batch_matmul with indexing_maps specifying transpose_b: RHS accesses (batch, n, k)
+// Activation: [4, 8, 10] = [batch, M, K]
+// Weight: [6, 10] = [N, K] (transposed)
+// After broadcast: [4, 6, 10] = [batch, N, K]
+// Output: [4, 8, 6] = [batch, M, N]
+// CHECK-LABEL: util.func public @broadcast_batch_matmul_transpose_b
+// CHECK-SAME: %[[ARG0:[a-zA-Z0-9]+]]: tensor<4x8x10xf32>, %[[ARG1:[a-zA-Z0-9]+]]: tensor<6x10xf32>
+// CHECK-DAG: %[[CST:.*]] = arith.constant 0.000000e+00 : f32
+// CHECK: %[[EMPTY:.*]] = tensor.empty() : tensor<4x8x6xf32>
+// CHECK: %[[FILL:.*]] = linalg.fill ins(%[[CST]] : f32) outs(%[[EMPTY]] : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+// CHECK: %[[COLLAPSED_ACT:.*]] = tensor.collapse_shape %[[ARG0]] {{\[}}[0, 1], [2]{{\]}} : tensor<4x8x10xf32> into tensor<32x10xf32>
+// CHECK: %[[COLLAPSED_OUT:.*]] = tensor.collapse_shape %[[FILL]] {{\[}}[0, 1], [2]{{\]}} : tensor<4x8x6xf32> into tensor<32x6xf32>
+// CHECK: %[[MATMUL:.*]] = linalg.matmul indexing_maps = [affine_map<(d0, d1, d2) -> (d0, d2)>, affine_map<(d0, d1, d2) -> (d1, d2)>, affine_map<(d0, d1, d2) -> (d0, d1)>] ins(%[[COLLAPSED_ACT]], %[[ARG1]] : tensor<32x10xf32>, tensor<6x10xf32>) outs(%[[COLLAPSED_OUT]] : tensor<32x6xf32>) -> tensor<32x6xf32>
+// CHECK: %[[EXPANDED:.*]] = tensor.expand_shape %[[MATMUL]] {{\[}}[0, 1], [2]{{\]}} output_shape [4, 8, 6] : tensor<32x6xf32> into tensor<4x8x6xf32>
+// CHECK: util.return %[[EXPANDED]] : tensor<4x8x6xf32>
+util.func public @broadcast_batch_matmul_transpose_b(%act: tensor<4x8x10xf32>, %weight: tensor<6x10xf32>) -> tensor<4x8x6xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x6x10xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<6x10xf32>)
+ outs(%init_broadcast : tensor<4x6x10xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<4x8x6xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+ // batch_matmul with transpose_b indexing: RHS is (batch, n, k) instead of (batch, k, n)
+ %result = linalg.batch_matmul
+ indexing_maps = [affine_map<(b, m, n, k) -> (b, m, k)>,
+ affine_map<(b, m, n, k) -> (b, n, k)>,
+ affine_map<(b, m, n, k) -> (b, m, n)>]
+ ins(%act, %broadcast : tensor<4x8x10xf32>, tensor<4x6x10xf32>)
+ outs(%fill : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+ util.return %result : tensor<4x8x6xf32>
+}
+
+// -----
+
+// Negative test - fused broadcast + transpose in generic (should NOT transform)
+// When transpose propagation folds a transpose into the broadcast generic, the
+// input map becomes (d0,d1,d2) -> (d2,d1) instead of (d0,d1,d2) -> (d1,d2).
+// The pass must not treat this as a pure broadcast.
+// CHECK-LABEL: util.func public @fused_broadcast_transpose_generic
+// CHECK: linalg.generic
+// CHECK: linalg.batch_matmul
+// CHECK-NOT: tensor.collapse_shape
+util.func public @fused_broadcast_transpose_generic(%act: tensor<4x10x8xf32>, %weight: tensor<6x8xf32>) -> tensor<4x10x6xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x8x6xf32>
+ // Generic that broadcasts on dim 0 AND transposes the inner dims
+ %broadcast_transpose = linalg.generic {
+ indexing_maps = [affine_map<(d0, d1, d2) -> (d2, d1)>,
+ affine_map<(d0, d1, d2) -> (d0, d1, d2)>],
+ iterator_types = ["parallel", "parallel", "parallel"]
+ } ins(%weight : tensor<6x8xf32>) outs(%init_broadcast : tensor<4x8x6xf32>) {
+ ^bb0(%in: f32, %out: f32):
+ linalg.yield %in : f32
+ } -> tensor<4x8x6xf32>
+ %init_out = tensor.empty() : tensor<4x10x6xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x10x6xf32>) -> tensor<4x10x6xf32>
+ %result = linalg.batch_matmul ins(%act, %broadcast_transpose : tensor<4x10x8xf32>, tensor<4x8x6xf32>)
+ outs(%fill : tensor<4x10x6xf32>) -> tensor<4x10x6xf32>
+ util.return %result : tensor<4x10x6xf32>
+}
+
+// -----
+
+// Transpose A variant (LHS activation is [batch, K, M] instead of [batch, M, K])
+// This should NOT transform because the collapse would produce mismatched shapes:
+// - LHS [batch, K, M] collapses to [batch*K, M]
+// - Out [batch, M, N] collapses to [batch*M, N]
+// These don't match, so transformation is invalid.
+// CHECK-LABEL: util.func public @broadcast_batch_matmul_transpose_a
+// CHECK: linalg.broadcast
+// CHECK: linalg.batch_matmul
+// CHECK-NOT: tensor.collapse_shape
+util.func public @broadcast_batch_matmul_transpose_a(%act: tensor<4x10x8xf32>, %weight: tensor<10x6xf32>) -> tensor<4x8x6xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x10x6xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<10x6xf32>)
+ outs(%init_broadcast : tensor<4x10x6xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<4x8x6xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+ // batch_matmul with transpose_a indexing: LHS is (batch, k, m) instead of (batch, m, k)
+ %result = linalg.batch_matmul
+ indexing_maps = [affine_map<(b, m, n, k) -> (b, k, m)>,
+ affine_map<(b, m, n, k) -> (b, k, n)>,
+ affine_map<(b, m, n, k) -> (b, m, n)>]
+ ins(%act, %broadcast : tensor<4x10x8xf32>, tensor<4x10x6xf32>)
+ outs(%fill : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+ util.return %result : tensor<4x8x6xf32>
+}
+
+// -----
+
+// Negative test - combined transpose_a and transpose_b (should NOT transform).
+// The LHS activation is [batch, K, M] and the RHS weight is [N, K]. For the
+// same reason as the transpose_a variant, because the LHS is transposed the
+// collapse would produce mismatched shapes:
+// - LHS [batch, K, M] collapses to [batch*K, M]
+// - Out [batch, M, N] collapses to [batch*M, N]
+// These don't match, so transformation is invalid. The combined form must be
+// rejected even though it is neither a pure transpose_a nor a pure transpose_b.
+// CHECK-LABEL: util.func public @broadcast_batch_matmul_transpose_a_and_b
+// CHECK: linalg.broadcast
+// CHECK: linalg.batch_matmul
+// CHECK-NOT: tensor.collapse_shape
+// CHECK-NOT: linalg.matmul
+util.func public @broadcast_batch_matmul_transpose_a_and_b(%act: tensor<4x10x8xf32>, %weight: tensor<6x10xf32>) -> tensor<4x8x6xf32> {
+ %init_broadcast = tensor.empty() : tensor<4x6x10xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<6x10xf32>)
+ outs(%init_broadcast : tensor<4x6x10xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<4x8x6xf32>
+ %cst = arith.constant 0.0 : f32
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+ // batch_matmul with transpose_a and transpose_b indexing: LHS is (b, k, m) and
+ // RHS is (b, n, k) instead of (b, m, k) and (b, k, n)
+ %result = linalg.batch_matmul
+ indexing_maps = [affine_map<(b, m, n, k) -> (b, k, m)>,
+ affine_map<(b, m, n, k) -> (b, n, k)>,
+ affine_map<(b, m, n, k) -> (b, m, n)>]
+ ins(%act, %broadcast : tensor<4x10x8xf32>, tensor<4x6x10xf32>)
+ outs(%fill : tensor<4x8x6xf32>) -> tensor<4x8x6xf32>
+ util.return %result : tensor<4x8x6xf32>
+}
diff --git a/tests/e2e/regression/BUILD.bazel b/tests/e2e/regression/BUILD.bazel
index e4bb04b..0991c05 100644
--- a/tests/e2e/regression/BUILD.bazel
+++ b/tests/e2e/regression/BUILD.bazel
@@ -68,6 +68,7 @@
iree_check_single_backend_test_suite(
name = "check_regression_llvm-cpu",
srcs = [
+ "broadcast_batch_matmul_to_matmul.mlir",
"layernorm.mlir",
"pack_pad_transpose_1x9_into_2x1x8x4_issue_12546.mlir",
"split_reduction_using_tiling.mlir",
diff --git a/tests/e2e/regression/CMakeLists.txt b/tests/e2e/regression/CMakeLists.txt
index 8ce9ed7..b8a5fc2 100644
--- a/tests/e2e/regression/CMakeLists.txt
+++ b/tests/e2e/regression/CMakeLists.txt
@@ -51,6 +51,7 @@
NAME
check_regression_llvm-cpu
SRCS
+ "broadcast_batch_matmul_to_matmul.mlir"
"i1_inlined_constant.mlir"
"layernorm.mlir"
"linalg_ops.mlir"
diff --git a/tests/e2e/regression/broadcast_batch_matmul_to_matmul.mlir b/tests/e2e/regression/broadcast_batch_matmul_to_matmul.mlir
new file mode 100644
index 0000000..316d6f3
--- /dev/null
+++ b/tests/e2e/regression/broadcast_batch_matmul_to_matmul.mlir
@@ -0,0 +1,60 @@
+// End-to-end correctness test for the batch-matmul-to-matmul pass
+// (--iree-global-opt-convert-batch-matmul-to-matmul), which runs as
+// part of the default global optimization pipeline.
+
+func.func @broadcast_batch_matmul() {
+ %act = util.unfoldable_constant dense<[[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
+ [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]]> : tensor<2x2x3xf32>
+ %weight = util.unfoldable_constant dense<[[1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 1.0],
+ [0.0, 0.0, 1.0, 1.0]]> : tensor<3x4xf32>
+ %cst = arith.constant 0.0 : f32
+ %init_broadcast = tensor.empty() : tensor<2x3x4xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<3x4xf32>)
+ outs(%init_broadcast : tensor<2x3x4xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<2x2x4xf32>
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<2x2x4xf32>) -> tensor<2x2x4xf32>
+
+ %opt = linalg.batch_matmul ins(%act, %broadcast : tensor<2x2x3xf32>, tensor<2x3x4xf32>)
+ outs(%fill : tensor<2x2x4xf32>) -> tensor<2x2x4xf32>
+
+ %broadcast_ref = util.optimization_barrier %broadcast : tensor<2x3x4xf32>
+ %ref = linalg.batch_matmul ins(%act, %broadcast_ref : tensor<2x2x3xf32>, tensor<2x3x4xf32>)
+ outs(%fill : tensor<2x2x4xf32>) -> tensor<2x2x4xf32>
+
+ check.expect_almost_eq(%opt, %ref) : tensor<2x2x4xf32>
+ return
+}
+
+func.func @broadcast_batch_matmul_transpose_b() {
+ %act = util.unfoldable_constant dense<[[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
+ [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]]> : tensor<2x2x3xf32>
+ %weight = util.unfoldable_constant dense<[[1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ [1.0, 1.0, 1.0]]> : tensor<4x3xf32>
+ %cst = arith.constant 0.0 : f32
+ %init_broadcast = tensor.empty() : tensor<2x4x3xf32>
+ %broadcast = linalg.broadcast ins(%weight : tensor<4x3xf32>)
+ outs(%init_broadcast : tensor<2x4x3xf32>) dimensions = [0]
+ %init_out = tensor.empty() : tensor<2x2x4xf32>
+ %fill = linalg.fill ins(%cst : f32) outs(%init_out : tensor<2x2x4xf32>) -> tensor<2x2x4xf32>
+
+ %opt = linalg.batch_matmul
+ indexing_maps = [affine_map<(b, m, n, k) -> (b, m, k)>,
+ affine_map<(b, m, n, k) -> (b, n, k)>,
+ affine_map<(b, m, n, k) -> (b, m, n)>]
+ ins(%act, %broadcast : tensor<2x2x3xf32>, tensor<2x4x3xf32>)
+ outs(%fill : tensor<2x2x4xf32>) -> tensor<2x2x4xf32>
+
+ %broadcast_ref = util.optimization_barrier %broadcast : tensor<2x4x3xf32>
+ %ref = linalg.batch_matmul
+ indexing_maps = [affine_map<(b, m, n, k) -> (b, m, k)>,
+ affine_map<(b, m, n, k) -> (b, n, k)>,
+ affine_map<(b, m, n, k) -> (b, m, n)>]
+ ins(%act, %broadcast_ref : tensor<2x2x3xf32>, tensor<2x4x3xf32>)
+ outs(%fill : tensor<2x2x4xf32>) -> tensor<2x2x4xf32>
+
+ check.expect_almost_eq(%opt, %ref) : tensor<2x2x4xf32>
+ return
+}