[Codegen][GlobalOpt] Detach non-splat constant inits (#24605)

A constant used directly as the `outs` (init/destination) of a
`linalg.matmul` or convolution e.g. a fully-connected/conv bias imported
from TFLite via TOSA survives into dispatch formation. It is then cloned
into the workgroup on the written (output) side and bufferizes as a
read-only global, which fails workgroup-distribution verification with
the following error:

```
'linalg.generic' op write affecting operations on global resources are restricted to workgroup distributed contexts
'func.func' op failed on workgroup distribution verification
```

This kind of IR was seen in TFLite -> TOSA conversion flows and and the
IR below is an example of that:

```mlir
util.func public @main(%a: tensor<1x1xf32>, %w: tensor<1x9xf32>) -> tensor<1x9xf32> {
  %bias = arith.constant dense<[[1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0]]> : tensor<1x9xf32>
  %0 = linalg.matmul ins(%a, %w : tensor<1x1xf32>, tensor<1x9xf32>)
                     outs(%bias : tensor<1x9xf32>) -> tensor<1x9xf32>
  util.return %0 : tensor<1x9xf32>
}
```

The chosen solution is to use is the `DetachElementwiseFromNamedOps`
pattern which exists to keep non-writable inits out of dispatches. It
only covers two of our three relevant cases. The pass will
1. Take a non-const init and detach it into a zero-fill plus a trailing
add
2. Take a splat constant init and convert it to a fill.

Our use-case of a non-splat constant init would not be handled. Here we
extend the elementwise detach pattern so a non-splat constant init is
detached the same way a computed init is. A fresh zero-fill is created
and the constant is added back as a read-only input. This form of IR can
be then handled by codegen.

After the fix the reproducer compiles and the bias survives read-only:

```
%bias = arith.constant ... : tensor<1x9xf32>
%z  = linalg.fill ins(%c0 : f32) outs(%empty : tensor<1x9xf32>) -> tensor<1x9xf32>
%mm = linalg.matmul ins(%a, %w) outs(%z) -> tensor<1x9xf32>
%0  = linalg.generic ins(%mm, %bias) outs(%z) { ... }   // %mm + %bias
```

---

_Code Examples and Tests Assisted-by Claude_
diff --git a/compiler/src/iree/compiler/GlobalOptimization/DetachElementwiseFromNamedOps.cpp b/compiler/src/iree/compiler/GlobalOptimization/DetachElementwiseFromNamedOps.cpp
index 24648f6..aa695f9 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/DetachElementwiseFromNamedOps.cpp
+++ b/compiler/src/iree/compiler/GlobalOptimization/DetachElementwiseFromNamedOps.cpp
@@ -58,11 +58,27 @@
     }
     Value outputOperand = outputOperands.front()->get();
 
-    auto outsDefiningOp = outputOperand.getDefiningOp<linalg::LinalgOp>();
-    if (!outsDefiningOp || isa<linalg::FillOp>(outsDefiningOp.getOperation())) {
-      // If not linalg op, or is a fill op, do nothing.
+    // By definition constants are read-only however some conversion paths
+    // place the bias initial value as the init/output argument of DPS
+    // operations. This later gets converted to a writable buffer. Here we
+    // detach the constant from the init argument to avoid this.
+    // Splat constant are handled later by DetachSplatConstantOutsOperands.
+    Operation *outsDefiningOp = outputOperand.getDefiningOp();
+    bool isNonSplatConstant = false;
+    if (auto constOp = dyn_cast_or_null<arith::ConstantOp>(outsDefiningOp)) {
+      auto elementsAttr = dyn_cast<ElementsAttr>(constOp.getValue());
+      isNonSplatConstant = elementsAttr && !elementsAttr.isSplat();
+    }
+
+    // outsDefiningOp is null for a block-argument init. The outsLinalgOp guard
+    // keeps the isa<FillOp> check off a null Operation*
+    auto outsLinalgOp = dyn_cast_or_null<linalg::LinalgOp>(outsDefiningOp);
+    bool needsDetach = isNonSplatConstant ||
+                       (outsLinalgOp && !isa<linalg::FillOp>(outsDefiningOp));
+    if (!needsDetach) {
       return failure();
     }
+
     auto outputType = cast<RankedTensorType>(outputOperand.getType());
     if (!outputType.getElementType().isIntOrFloat()) {
       return failure();
@@ -124,6 +140,9 @@
 /// with allocations. Using `fill` will allow for fusing with the op just like
 /// fill -> linalg ops are fused. If not as a fallback they would be converted
 /// to a splat, but both without stack allocations.
+///
+/// Non-splat constant `outs` inits on contractions/convolutions are detached
+/// separately by DetachElementwisePattern (zero fill + trailing add).
 template <typename InterfaceOp>
 struct DetachSplatConstantOutsOperands
     : OpInterfaceRewritePattern<InterfaceOp> {
diff --git a/compiler/src/iree/compiler/GlobalOptimization/test/detach_elementwise_from_named_ops.mlir b/compiler/src/iree/compiler/GlobalOptimization/test/detach_elementwise_from_named_ops.mlir
index 870e351..7075e0e 100644
--- a/compiler/src/iree/compiler/GlobalOptimization/test/detach_elementwise_from_named_ops.mlir
+++ b/compiler/src/iree/compiler/GlobalOptimization/test/detach_elementwise_from_named_ops.mlir
@@ -201,3 +201,55 @@
 //       CHECK:   %[[GENERIC:.+]] = linalg.generic
 //  CHECK-SAME:       outs(%[[FILL]] :
 //       CHECK:   util.return %[[GENERIC]]
+
+// -----
+
+/// A non-splat constant init on a contraction must be detached: a constant
+/// cannot be a writable dispatch output. It is replaced with a zero fill and
+/// the constant is added back via a trailing elementwise op (as a read-only
+/// input).
+util.func public @matmul_nonsplat_cst_output(%arg0: tensor<2x3xf32>, %arg1: tensor<3x2xf32>) -> tensor<2x2xf32> {
+  %cst = arith.constant dense<[[1.0, 2.0], [3.0, 4.0]]> : tensor<2x2xf32>
+  %0 = linalg.matmul ins(%arg0, %arg1 : tensor<2x3xf32>, tensor<3x2xf32>)
+                     outs(%cst : tensor<2x2xf32>) -> tensor<2x2xf32>
+  util.return %0 : tensor<2x2xf32>
+}
+// CHECK-LABEL: util.func public @matmul_nonsplat_cst_output
+//  CHECK-SAME:     %[[ARG0:.+]]: tensor<2x3xf32>, %[[ARG1:.+]]: tensor<3x2xf32>
+//   CHECK-DAG:   %[[CST:.+]] = arith.constant dense<{{.*}}> : tensor<2x2xf32>
+//   CHECK-DAG:   %[[F0:.+]] = arith.constant 0.000000e+00 : f32
+//       CHECK:   %[[INIT:.+]] = tensor.empty() : tensor<2x2xf32>
+//       CHECK:   %[[FILL:.+]] = linalg.fill ins(%[[F0]] : f32) outs(%[[INIT]] : tensor<2x2xf32>)
+//       CHECK:   %[[MM:.+]] = linalg.matmul
+//  CHECK-SAME:       ins(%[[ARG0]], %[[ARG1]] : tensor<2x3xf32>, tensor<3x2xf32>)
+//  CHECK-SAME:       outs(%[[FILL]] : tensor<2x2xf32>)
+//       CHECK:   %[[EW:.+]] = linalg.generic
+//  CHECK-SAME:       ins(%[[MM]], %[[CST]] : tensor<2x2xf32>, tensor<2x2xf32>)
+//  CHECK-SAME:       outs(%[[FILL]] : tensor<2x2xf32>)
+//       CHECK:     arith.addf
+//       CHECK:   util.return %[[EW]]
+
+// -----
+
+/// A non-splat constant init on a convolution is detached the same way as a
+/// contraction: zero fill + trailing add, with the constant as a read-only
+/// input.
+util.func public @conv_nonsplat_cst_output(%input: tensor<1x4x4x2xf32>, %filter: tensor<3x3x2x2xf32>) -> tensor<1x2x2x2xf32> {
+  %cst = arith.constant dense<[[[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]]> : tensor<1x2x2x2xf32>
+  %0 = linalg.conv_2d_nhwc_hwcf {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+      ins(%input, %filter : tensor<1x4x4x2xf32>, tensor<3x3x2x2xf32>)
+      outs(%cst : tensor<1x2x2x2xf32>) -> tensor<1x2x2x2xf32>
+  util.return %0 : tensor<1x2x2x2xf32>
+}
+// CHECK-LABEL: util.func public @conv_nonsplat_cst_output
+//   CHECK-DAG:   %[[CST:.+]] = arith.constant dense<{{.*}}> : tensor<1x2x2x2xf32>
+//   CHECK-DAG:   %[[F0:.+]] = arith.constant 0.000000e+00 : f32
+//       CHECK:   %[[INIT:.+]] = tensor.empty() : tensor<1x2x2x2xf32>
+//       CHECK:   %[[FILL:.+]] = linalg.fill ins(%[[F0]] : f32) outs(%[[INIT]] : tensor<1x2x2x2xf32>)
+//       CHECK:   %[[CONV:.+]] = linalg.conv_2d_nhwc_hwcf
+//  CHECK-SAME:       outs(%[[FILL]] : tensor<1x2x2x2xf32>)
+//       CHECK:   %[[EW:.+]] = linalg.generic
+//  CHECK-SAME:       ins(%[[CONV]], %[[CST]] : tensor<1x2x2x2xf32>, tensor<1x2x2x2xf32>)
+//  CHECK-SAME:       outs(%[[FILL]] : tensor<1x2x2x2xf32>)
+//       CHECK:     arith.addf
+//       CHECK:   util.return %[[EW]]