[Codegen][CPU] Fold reshapes into map_store per reassociation group. (#24535)

`foldReshapeIntoMapStore` composed an expand/collapse reshape into a
`map_store`'s index transformation via one global `linearize` over the
source shape plus `delinearize` over the result shape. Under dynamic
shapes that `delinearize` lowers to a dynamic integer division per
element.

Fold each reassociation group independently instead: an `expand_shape`
splits one source index by just that group's result sizes; a
`collapse_shape` merges one group of source indices. The split/merge
factors are then per-group -- typically static tile sizes -- so the
scalarized transformation is static shifts/masks with no per-element
division.

Speeds up the i8 data-tiled matmul's C-encode dispatch; i8 inner_tiled
matmul, 4096x4096 on Zen 4: 52.9ms -> 46.9ms.

Also document, at `MapStoreOpVectorizationModel::isVectorizable`, why a
CPU-tiled `map_store` reaches the vectorizer dynamically shaped and
scalarizes -- which is precisely what makes this per-group,
division-free fold matter -- while the same op vectorizes on GPU.
Comment only, no behavior change.

Progress towards #24515.

Signed-off-by: Benoit Jacob <jacob.benoit.1@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/compiler/src/iree/compiler/Codegen/Common/CombineLayoutTransformation.cpp b/compiler/src/iree/compiler/Codegen/Common/CombineLayoutTransformation.cpp
index 6fbebb6..9db8fcb 100644
--- a/compiler/src/iree/compiler/Codegen/Common/CombineLayoutTransformation.cpp
+++ b/compiler/src/iree/compiler/Codegen/Common/CombineLayoutTransformation.cpp
@@ -77,8 +77,16 @@
 }
 
 /// Fold a tensor::ExpandShapeOp or tensor::CollapseShapeOp into a consumer
-/// `mapStoreOp`, by linearizing and then delinearizing the source indices
-/// of the `mapStoreOp`s index transformation.
+/// `mapStoreOp` by composing the reshape's index transformation into the
+/// front of the `mapStoreOp`'s.
+///
+/// Each reassociation group is folded independently: an `expand_shape` splits
+/// one source index via a `delinearize` over just that group's result sizes,
+/// and a `collapse_shape` merges a group of source indices via a `linearize`.
+/// This keeps the split/merge factors per-group — typically static tile sizes
+/// — rather than routing every index through one global `linearize` +
+/// `delinearize` over the full (dynamic) shapes, which would force a dynamic
+/// integer division per element when the transformation is later scalarized.
 template <typename ReshapeOpTy>
 static FailureOr<IREE::LinalgExt::MapStoreOp>
 foldReshapeIntoMapStore(RewriterBase &rewriter, ReshapeOpTy reshapeOp,
@@ -92,6 +100,7 @@
       cast<RankedTensorType>(reshapeOp.getResult().getType()).getRank() == 0) {
     return failure();
   }
+  constexpr bool isExpand = std::is_same_v<ReshapeOpTy, tensor::ExpandShapeOp>;
   Location loc = reshapeOp->getLoc();
   OpBuilder::InsertionGuard g(rewriter);
   rewriter.setInsertionPointAfter(reshapeOp);
@@ -102,20 +111,53 @@
   // sizes by later cleanup patterns.
   SmallVector<OpFoldResult> resultDims =
       tensor::getMixedSizes(rewriter, loc, reshapeOp.getResult());
+  SmallVector<ReassociationIndices> reassociation =
+      reshapeOp.getReassociationIndices();
 
   Location mapStoreLoc = mapStoreOp->getLoc();
   rewriter.modifyOpInPlace(mapStoreOp, [&]() {
     mapStoreOp.insertTransformationAtStart(
         rewriter,
-        [&rewriter, mapStoreLoc, srcDims,
-         resultDims](ArrayRef<BlockArgument> indices) -> SmallVector<Value> {
-          SmallVector<Value> indexValues(indices.begin(), indices.end());
-          auto linearizeIndexOp = affine::AffineLinearizeIndexOp::create(
-              rewriter, mapStoreLoc, indexValues, srcDims, /*disjoint=*/true);
-          auto delinearizeIndexOp = affine::AffineDelinearizeIndexOp::create(
-              rewriter, mapStoreLoc, linearizeIndexOp.getResult(), resultDims,
-              /*hasOuterBound=*/true);
-          return delinearizeIndexOp->getResults();
+        [&rewriter, mapStoreLoc, srcDims, resultDims, reassociation,
+         isExpand](ArrayRef<BlockArgument> indices) -> SmallVector<Value> {
+          // `indices` are in the reshape's source index space; produce the
+          // reshape's result index space.
+          SmallVector<Value> result(resultDims.size());
+          for (auto [groupIdx, group] : llvm::enumerate(reassociation)) {
+            if (isExpand) {
+              // Source index `groupIdx` splits into result dims `group`.
+              if (group.size() == 1) {
+                result[group[0]] = indices[groupIdx];
+                continue;
+              }
+              SmallVector<OpFoldResult> groupSizes;
+              for (int64_t resultDim : group) {
+                groupSizes.push_back(resultDims[resultDim]);
+              }
+              auto delinearizeOp = affine::AffineDelinearizeIndexOp::create(
+                  rewriter, mapStoreLoc, indices[groupIdx], groupSizes,
+                  /*hasOuterBound=*/true);
+              for (auto [i, resultDim] : llvm::enumerate(group)) {
+                result[resultDim] = delinearizeOp.getResult(i);
+              }
+            } else {
+              // Source dims `group` merge into result index `groupIdx`.
+              if (group.size() == 1) {
+                result[groupIdx] = indices[group[0]];
+                continue;
+              }
+              SmallVector<Value> groupIndices;
+              SmallVector<OpFoldResult> groupSizes;
+              for (int64_t srcDim : group) {
+                groupIndices.push_back(indices[srcDim]);
+                groupSizes.push_back(srcDims[srcDim]);
+              }
+              result[groupIdx] = affine::AffineLinearizeIndexOp::create(
+                  rewriter, mapStoreLoc, groupIndices, groupSizes,
+                  /*disjoint=*/true);
+            }
+          }
+          return result;
         },
         srcDims.size());
     mapStoreOp.getInputMutable().assign(reshapeOp->getOperand(0));
diff --git a/compiler/src/iree/compiler/Codegen/Common/test/combine_result_layout_transformation.mlir b/compiler/src/iree/compiler/Codegen/Common/test/combine_result_layout_transformation.mlir
index 3284f32..872d11b 100644
--- a/compiler/src/iree/compiler/Codegen/Common/test/combine_result_layout_transformation.mlir
+++ b/compiler/src/iree/compiler/Codegen/Common/test/combine_result_layout_transformation.mlir
@@ -4,6 +4,12 @@
 // RUN:   -split-input-file %s | FileCheck %s --check-prefixes=WORKGROUP-SCOPE
 // RUN: iree-opt --pass-pipeline="builtin.module(func.func(iree-codegen-combine-result-layout-transformation{scope=dispatch-reshape},canonicalize,cse))" \
 // RUN:   -split-input-file %s | FileCheck %s --check-prefixes=RESHAPE-SCOPE
+// Scalarize the index transformations to show that the per-group fold keeps
+// the lowered body division-free, while a global `delinearize_index` over a
+// dynamic shape would scalarize to runtime-divisor `arith.divsi`/`remsi`.
+// Gated on the EXPAND prefix.
+// RUN: iree-opt --pass-pipeline="builtin.module(func.func(iree-codegen-combine-result-layout-transformation{scope=dispatch},canonicalize,cse,affine-expand-index-ops,canonicalize,cse))" \
+// RUN:   -split-input-file %s | FileCheck %s --check-prefixes=EXPAND
 
 func.func @fold_collapse_shape_op(%source : tensor<2x4x16xf32>, %result : memref<8x16xf32>) {
   %collapse = tensor.collapse_shape %source [[0, 1], [2]] : tensor<2x4x16xf32> into tensor<8x16xf32>
@@ -250,7 +256,6 @@
   iree_codegen.store_to_buffer %unpack, %result : tensor<?x?xf32> into memref<?x?xf32>
   return
 }
-//       DISPATCH-SCOPE: #[[$MAP:.+]] = affine_map<()[s0] -> (s0 * 128)>
 // DISPATCH-SCOPE-LABEL: @fold_unpack_op
 //  DISPATCH-SCOPE-SAME:   %[[SOURCE:[a-zA-Z0-9_]+]]
 //  DISPATCH-SCOPE-SAME:   %[[RESULT:[a-zA-Z0-9_]+]]
@@ -260,26 +265,65 @@
 //   DISPATCH-SCOPE-DAG:   %[[RES_D1:.+]] = memref.dim %[[RESULT]], %[[C1]] : memref<?x?xf32>
 //   DISPATCH-SCOPE-DAG:   %[[SRC_D0:.+]] = tensor.dim %[[SOURCE]], %[[C0]] : tensor<?x?x128x128xf32>
 //   DISPATCH-SCOPE-DAG:   %[[SRC_D1:.+]] = tensor.dim %[[SOURCE]], %[[C1]] : tensor<?x?x128x128xf32>
-//   DISPATCH-SCOPE-DAG:   %[[COLLAPSE_SIZE0:.+]] = affine.apply #[[$MAP]]()[%[[SRC_D0]]]
-//   DISPATCH-SCOPE-DAG:   %[[COLLAPSE_SIZE1:.+]] = affine.apply #[[$MAP]]()[%[[SRC_D1]]]
 //   DISPATCH-SCOPE-DAG:   %[[MAP_SCATTER_DEST:.+]] = tensor.empty(%[[RES_D0]], %[[RES_D1]]) : tensor<?x?xf32>
 //       DISPATCH-SCOPE:   %[[MAP_SCATTER:.+]] = iree_linalg_ext.map_store
 //  DISPATCH-SCOPE-SAME:     %[[SOURCE]] into %[[MAP_SCATTER_DEST]] {
 //  DISPATCH-SCOPE-NEXT:   ^bb0(%[[IDX0:.+]]: index, %[[IDX1:.+]]: index, %[[IDX2:.+]]: index, %[[IDX3:.+]]: index):
-//       DISPATCH-SCOPE:     %[[LINEARIZE:.+]] = affine.linearize_index
-//  DISPATCH-SCOPE-SAME:       [%[[IDX0]], %[[IDX2]], %[[IDX1]], %[[IDX3]]]
-//  DISPATCH-SCOPE-SAME:       by (%[[SRC_D0]], 128, %[[SRC_D1]], 128)
-//       DISPATCH-SCOPE:     %[[DELINEARIZE:.+]]:2 = affine.delinearize_index %[[LINEARIZE]]
-//  DISPATCH-SCOPE-SAME:       into (%[[COLLAPSE_SIZE0]], %[[COLLAPSE_SIZE1]])
-//       DISPATCH-SCOPE:     %[[BOUND0:.+]] = arith.cmpi ult, %[[DELINEARIZE]]#0, %[[RES_D0]]
-//       DISPATCH-SCOPE:     %[[BOUND1:.+]] = arith.cmpi ult, %[[DELINEARIZE]]#1, %[[RES_D1]]
+// Each collapsed dimension folds independently, so the merge factors stay
+// per-group rather than going through one global linearize + delinearize.
+//       DISPATCH-SCOPE:     %[[LIN0:.+]] = affine.linearize_index disjoint
+//  DISPATCH-SCOPE-SAME:       [%[[IDX0]], %[[IDX2]]] by (%[[SRC_D0]], 128)
+//       DISPATCH-SCOPE:     %[[LIN1:.+]] = affine.linearize_index disjoint
+//  DISPATCH-SCOPE-SAME:       [%[[IDX1]], %[[IDX3]]] by (%[[SRC_D1]], 128)
+//       DISPATCH-SCOPE:     %[[BOUND0:.+]] = arith.cmpi ult, %[[LIN0]], %[[RES_D0]]
+//       DISPATCH-SCOPE:     %[[BOUND1:.+]] = arith.cmpi ult, %[[LIN1]], %[[RES_D1]]
 //       DISPATCH-SCOPE:     %[[MASK:.+]] = arith.andi %[[BOUND0]], %[[BOUND1]] : i1
-//       DISPATCH-SCOPE:     iree_linalg_ext.yield %[[DELINEARIZE]]#0, %[[DELINEARIZE]]#1, %[[MASK]]
+//       DISPATCH-SCOPE:     iree_linalg_ext.yield %[[LIN0]], %[[LIN1]], %[[MASK]]
 //       DISPATCH-SCOPE:   } : tensor<?x?x128x128xf32> into tensor<?x?xf32> -> tensor<?x?xf32>
 //       DISPATCH-SCOPE:   iree_codegen.store_to_buffer %[[MAP_SCATTER]], %[[RESULT]] : tensor<?x?xf32> into memref<?x?xf32>
 
 // -----
 
+// Dynamic-shape companion of @fold_pack_op. Mirrors @fold_unpack_op's
+// 2-reassociation-group shape (dim -> dynamic-outer + 128-tile), but going
+// the other way -- exercises the expand-case per-group `delinearize_index`
+// path on dimensions that don't canonicalize away (the case the pre-PR
+// global linearize + delinearize would have scalarized to a per-element
+// dynamic integer division).
+func.func @fold_pack_op_dynamic(%source : tensor<?x?xf32>, %result : memref<?x?x128x128xf32>) {
+  %c0 = arith.constant 0 : index
+  %c1 = arith.constant 1 : index
+  %cst = arith.constant 0.0 : f32
+  %d0 = memref.dim %result, %c0 : memref<?x?x128x128xf32>
+  %d1 = memref.dim %result, %c1 : memref<?x?x128x128xf32>
+  %dest = tensor.empty(%d0, %d1) : tensor<?x?x128x128xf32>
+  %pack = linalg.pack %source padding_value(%cst : f32)
+      outer_dims_perm = [0, 1] inner_dims_pos = [0, 1] inner_tiles = [128, 128]
+      into %dest : tensor<?x?xf32> -> tensor<?x?x128x128xf32>
+  iree_codegen.store_to_buffer %pack, %result : tensor<?x?x128x128xf32> into memref<?x?x128x128xf32>
+  return
+}
+// DISPATCH-SCOPE-LABEL: @fold_pack_op_dynamic
+//  DISPATCH-SCOPE-SAME:   %[[SOURCE:[a-zA-Z0-9_]+]]
+//  DISPATCH-SCOPE-SAME:   %[[RESULT:[a-zA-Z0-9_]+]]
+//   DISPATCH-SCOPE-DAG:   %[[C0:.+]] = arith.constant 0 : index
+//   DISPATCH-SCOPE-DAG:   %[[C1:.+]] = arith.constant 1 : index
+//   DISPATCH-SCOPE-DAG:   %[[RES_D0:.+]] = memref.dim %[[RESULT]], %[[C0]] : memref<?x?x128x128xf32>
+//   DISPATCH-SCOPE-DAG:   %[[RES_D1:.+]] = memref.dim %[[RESULT]], %[[C1]] : memref<?x?x128x128xf32>
+//   DISPATCH-SCOPE-DAG:   %[[MAP_SCATTER_DEST:.+]] = tensor.empty(%[[RES_D0]], %[[RES_D1]]) : tensor<?x?x128x128xf32>
+//       DISPATCH-SCOPE:   %[[MAP_SCATTER:.+]] = iree_linalg_ext.map_store
+//  DISPATCH-SCOPE-SAME:     %[[SOURCE]] into %[[MAP_SCATTER_DEST]] {
+//  DISPATCH-SCOPE-NEXT:   ^bb0(%[[IDX0:.+]]: index, %[[IDX1:.+]]: index):
+// Each expanded dimension folds independently, so the split factors stay
+// per-group rather than going through one global linearize + delinearize.
+//       DISPATCH-SCOPE:     %[[DELIN0:.+]]:2 = affine.delinearize_index %[[IDX0]] into (%[[RES_D0]], 128)
+//       DISPATCH-SCOPE:     %[[DELIN1:.+]]:2 = affine.delinearize_index %[[IDX1]] into (%[[RES_D1]], 128)
+//       DISPATCH-SCOPE:     iree_linalg_ext.yield %[[DELIN0]]#0, %[[DELIN1]]#0, %[[DELIN0]]#1, %[[DELIN1]]#1
+//       DISPATCH-SCOPE:   } : tensor<?x?xf32> into tensor<?x?x128x128xf32> -> tensor<?x?x128x128xf32>
+//       DISPATCH-SCOPE:   iree_codegen.store_to_buffer %[[MAP_SCATTER]], %[[RESULT]] : tensor<?x?x128x128xf32> into memref<?x?x128x128xf32>
+
+// -----
+
 func.func @fold_pack_op(%source : tensor<250x250xf32>, %result : memref<2x2x128x128xf32>) {
   %cst = arith.constant 0.0 : f32
   %dest = tensor.empty() : tensor<2x2x128x128xf32>
@@ -600,3 +644,55 @@
 // RESHAPE-SCOPE-LABEL: @pure_transpose_skipped_under_dispatch_reshape
 //       RESHAPE-SCOPE:   linalg.transpose
 //   RESHAPE-SCOPE-NOT:   iree_linalg_ext.map_store
+
+// -----
+
+// Lowering contrast: scalarizing the per-group `(de)linearize_index` the
+// combine pass emits leaves the body division-free -- multiplies and adds by
+// the static tile size -- while a global `delinearize_index` over the full
+// dynamic result shape scalarizes to runtime-divisor `arith.divsi`/`remsi`.
+
+func.func @lowered_per_group_no_division(%source : tensor<?x?x128x128xf32>,
+                                          %result : memref<?x?xf32>) {
+  %c0 = arith.constant 0 : index
+  %c1 = arith.constant 1 : index
+  %d0 = memref.dim %result, %c0 : memref<?x?xf32>
+  %d1 = memref.dim %result, %c1 : memref<?x?xf32>
+  %dest = tensor.empty(%d0, %d1) : tensor<?x?xf32>
+  %unpack = linalg.unpack %source
+      outer_dims_perm = [0, 1] inner_dims_pos = [0, 1] inner_tiles = [128, 128]
+      into %dest : tensor<?x?x128x128xf32> -> tensor<?x?xf32>
+  iree_codegen.store_to_buffer %unpack, %result : tensor<?x?xf32> into memref<?x?xf32>
+  return
+}
+// The per-group `linearize_index` lowers to `muli`/`addi` by the static tile
+// size 128 -- no division of any kind.
+//       EXPAND-LABEL: @lowered_per_group_no_division
+//         EXPAND-DAG: %[[C128:.+]] = arith.constant 128 : index
+//             EXPAND: iree_linalg_ext.map_store
+//             EXPAND: arith.muli %{{.+}}, %[[C128]]
+//             EXPAND: arith.addi
+//             EXPAND: arith.muli %{{.+}}, %[[C128]]
+//             EXPAND: arith.addi
+//         EXPAND-NOT: arith.divsi
+//         EXPAND-NOT: arith.floordivsi
+//         EXPAND-NOT: arith.remsi
+
+// -----
+
+// Negative case for contrast: a global `affine.delinearize_index` over a
+// fully dynamic result shape -- the per-group fold avoids producing this IR.
+func.func @lowered_global_dynamic_divisor(
+    %idx: index, %d0: index, %d1: index, %d2: index, %d3: index
+) -> (index, index, index, index) {
+  %r:4 = affine.delinearize_index %idx into (%d0, %d1, %d2, %d3)
+       : index, index, index, index
+  return %r#0, %r#1, %r#2, %r#3 : index, index, index, index
+}
+// Scalarizes to runtime-divisor `arith.divsi`/`remsi` (divisors are
+// `arith.muli` products of the dynamic factors) -- the cost the per-group
+// fold avoids.
+//       EXPAND-LABEL: @lowered_global_dynamic_divisor
+//             EXPAND: arith.muli
+//             EXPAND: arith.divsi
+//             EXPAND: arith.remsi
diff --git a/compiler/src/iree/compiler/Codegen/Common/test/generic_vectorization_map_store.mlir b/compiler/src/iree/compiler/Codegen/Common/test/generic_vectorization_map_store.mlir
index afe51c6..8df64a3 100644
--- a/compiler/src/iree/compiler/Codegen/Common/test/generic_vectorization_map_store.mlir
+++ b/compiler/src/iree/compiler/Codegen/Common/test/generic_vectorization_map_store.mlir
@@ -139,3 +139,78 @@
 }
 // CHECK-LABEL: @map_store_f4_mask_depends_on_inner_index
 //   CHECK-NOT:   vector
+
+// -----
+
+// A `map_store` whose body uses the per-group `affine.linearize_index disjoint`
+// form -- the IR shape `foldReshapeIntoMapStore` emits when folding a
+// `collapse_shape` per reassociation group rather than via one global linearize
+// + delinearize. Vectorization accepts it when the input is statically shaped
+// (the relevant criterion is `inputType.hasStaticShape()`, the index
+// transformation in the body is opaque to that check).
+func.func @vectorize_map_store_per_group_linearize(
+    %input: tensor<2x4x16xf32>, %output: tensor<8x16xf32>
+) -> tensor<8x16xf32> {
+  %0 = iree_linalg_ext.map_store %input into %output {
+    ^bb0(%idx0: index, %idx1: index, %idx2: index):
+      %lin = affine.linearize_index disjoint [%idx0, %idx1] by (2, 4) : index
+      %mask = arith.constant true
+      iree_linalg_ext.yield %lin, %idx2, %mask : index, index, i1
+  } : tensor<2x4x16xf32> into tensor<8x16xf32> -> tensor<8x16xf32>
+  return %0 : tensor<8x16xf32>
+}
+// CHECK-LABEL: @vectorize_map_store_per_group_linearize
+//  CHECK-SAME:     %[[INPUT:[a-zA-Z0-9_]+]]
+//  CHECK-SAME:     %[[OUTPUT:[a-zA-Z0-9_]+]]
+//       CHECK:   %[[READ:.+]] = vector.transfer_read %[[INPUT]]
+//       CHECK:   %[[MAP_SCATTER:.+]] = iree_linalg_ext.map_store
+//  CHECK-SAME:     %[[READ]] into %[[OUTPUT]]
+//       CHECK:     : vector<2x4x16xf32> into tensor<8x16xf32> -> tensor<8x16xf32>
+
+// -----
+
+// Expand-direction companion of the above: per-group `affine.delinearize_index`
+// in the body (the shape emitted when folding an `expand_shape` per
+// reassociation group). Same story -- vectorization is gated on the input's
+// static shape, not on what the index transformation looks like.
+func.func @vectorize_map_store_per_group_delinearize(
+    %input: tensor<8x16xf32>, %output: tensor<2x4x16xf32>
+) -> tensor<2x4x16xf32> {
+  %0 = iree_linalg_ext.map_store %input into %output {
+    ^bb0(%idx0: index, %idx1: index):
+      %delin:2 = affine.delinearize_index %idx0 into (2, 4) : index, index
+      %mask = arith.constant true
+      iree_linalg_ext.yield %delin#0, %delin#1, %idx1, %mask : index, index, index, i1
+  } : tensor<8x16xf32> into tensor<2x4x16xf32> -> tensor<2x4x16xf32>
+  return %0 : tensor<2x4x16xf32>
+}
+// CHECK-LABEL: @vectorize_map_store_per_group_delinearize
+//  CHECK-SAME:     %[[INPUT:[a-zA-Z0-9_]+]]
+//  CHECK-SAME:     %[[OUTPUT:[a-zA-Z0-9_]+]]
+//       CHECK:   %[[READ:.+]] = vector.transfer_read %[[INPUT]]
+//       CHECK:   %[[MAP_SCATTER:.+]] = iree_linalg_ext.map_store
+//  CHECK-SAME:     %[[READ]] into %[[OUTPUT]]
+//       CHECK:     : vector<8x16xf32> into tensor<2x4x16xf32> -> tensor<2x4x16xf32>
+
+// -----
+
+// Dynamic-input counterpart of @vectorize_map_store_per_group_delinearize:
+// the per-group form is what reaches this point in practice for CPU-tiled
+// encoding relayouts, but the vectorizer still skips it because the input is
+// dynamically shaped -- consistent with @no_vectorize_map_store_dynamic above.
+// The per-group fold's payoff is at the later scalarization stage (static
+// shifts/masks instead of dynamic integer division per element), not at
+// vectorization.
+func.func @no_vectorize_map_store_per_group_delinearize_dynamic(
+    %input: tensor<?xf32>, %output: tensor<?x4xf32>, %d0 : index
+) -> tensor<?x4xf32> {
+  %0 = iree_linalg_ext.map_store %input into %output {
+    ^bb0(%idx0: index):
+      %delin:2 = affine.delinearize_index %idx0 into (%d0, 4) : index, index
+      %mask = arith.constant true
+      iree_linalg_ext.yield %delin#0, %delin#1, %mask : index, index, i1
+  } : tensor<?xf32> into tensor<?x4xf32> -> tensor<?x4xf32>
+  return %0 : tensor<?x4xf32>
+}
+// CHECK-LABEL: @no_vectorize_map_store_per_group_delinearize_dynamic
+//   CHECK-NOT:   vector
diff --git a/compiler/src/iree/compiler/Codegen/Interfaces/VectorizableOpInterface.cpp b/compiler/src/iree/compiler/Codegen/Interfaces/VectorizableOpInterface.cpp
index 9ae3075..ac79fbb 100644
--- a/compiler/src/iree/compiler/Codegen/Interfaces/VectorizableOpInterface.cpp
+++ b/compiler/src/iree/compiler/Codegen/Interfaces/VectorizableOpInterface.cpp
@@ -462,6 +462,18 @@
       return false;
     }
     ShapedType inputType = mapStoreOp.getInputType();
+    // `vectorize()` below emits an unmasked, full-tile `vector.transfer_read`,
+    // so the input must be statically shaped. A `map_store` iterates its
+    // *input*, so it only vectorizes when that input is already statically
+    // tiled. For a data-tiling encoding relayout the input is the un-padded
+    // source tensor, which has no static tile structure -- so a CPU-tiled
+    // `map_store` reaches here dynamically shaped and is left to scalarize.
+    // The padded, statically-tiled side of an encoding relayout is its
+    // *output*; a relayout rooted there -- a `map_load`, which iterates its
+    // result -- tiles into complete static tiles by construction. So a CPU
+    // encoding relayout should be combined into a `map_load`, not a
+    // `map_store`. (GPU also forms `map_store` for these but tiles them
+    // statically via its thread-grid distribution, so it does not hit this.)
     if (!inputType.hasStaticShape()) {
       return false;
     }