[Codegen][VectorExt] Add TransferScatterOp unrolling support (#23704)

Add scatter unrolling to `LowerTransferGatherScatterOps` (renamed from
`LowerTransferGatherOps`). Templatized helpers with gather:
`removeDim0FromMap`, `computeUnrollDim0Maps`,
`extractSlicesForIteration`, `computeNewOffsets`.

Added subsequent lit tests.

Co-Authored-By: Claude Opus 4.6
[noreply@anthropic.com](mailto:noreply@anthropic.com)

Part 4/4 from https://github.com/iree-org/iree/pull/23610

---------

Signed-off-by: Keshav Vinayak Jha <keshavvinayakjha@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/BUILD.bazel b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/BUILD.bazel
index 8827f14..a31b928 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/BUILD.bazel
+++ b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/BUILD.bazel
@@ -37,7 +37,7 @@
     srcs = [
         "BufferizationInterfaces.cpp",
         "DistributionPatterns.cpp",
-        "LowerTransferGatherOps.cpp",
+        "LowerTransferGatherScatterOps.cpp",
         "Passes.cpp",
         "VectorExtFoldUnitExtentDims.cpp",
     ],
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/CMakeLists.txt b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/CMakeLists.txt
index 57219b5..1e53aa4 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/CMakeLists.txt
+++ b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/CMakeLists.txt
@@ -31,7 +31,7 @@
   SRCS
     "BufferizationInterfaces.cpp"
     "DistributionPatterns.cpp"
-    "LowerTransferGatherOps.cpp"
+    "LowerTransferGatherScatterOps.cpp"
     "Passes.cpp"
     "VectorExtFoldUnitExtentDims.cpp"
   DEPS
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/LowerTransferGatherOps.cpp b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/LowerTransferGatherOps.cpp
deleted file mode 100644
index 74db010..0000000
--- a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/LowerTransferGatherOps.cpp
+++ /dev/null
@@ -1,235 +0,0 @@
-// Copyright 2025 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/Codegen/Dialect/VectorExt/IR/VectorExtOps.h"
-#include "iree/compiler/Codegen/Dialect/VectorExt/Transforms/Transforms.h"
-#include "mlir/Dialect/Arith/IR/Arith.h"
-#include "mlir/Dialect/UB/IR/UBOps.h"
-#include "mlir/Dialect/Vector/IR/VectorOps.h"
-#include "mlir/IR/AffineMap.h"
-#include "mlir/IR/PatternMatch.h"
-
-using namespace mlir;
-using namespace mlir::iree_compiler::IREE::VectorExt;
-
-namespace {
-
-/// Remove dim 0 from an AffineMap by:
-/// 1. Replacing AffineDimExpr(0) with AffineConstantExpr(0)
-/// 2. Renumbering AffineDimExpr(k) where k > 0 to AffineDimExpr(k-1)
-/// 3. Reducing numDims by 1
-static AffineMap removeDim0FromMap(AffineMap map) {
-  MLIRContext *ctx = map.getContext();
-  SmallVector<AffineExpr> newResults;
-  for (AffineExpr expr : map.getResults()) {
-    if (auto dimExpr = dyn_cast<AffineDimExpr>(expr)) {
-      unsigned pos = dimExpr.getPosition();
-      if (pos == 0) {
-        newResults.push_back(getAffineConstantExpr(0, ctx));
-      } else {
-        newResults.push_back(getAffineDimExpr(pos - 1, ctx));
-      }
-    } else {
-      newResults.push_back(expr);
-    }
-  }
-  return AffineMap::get(map.getNumDims() - 1, map.getNumSymbols(), newResults,
-                        ctx);
-}
-
-/// Remove dim 0 references from an index vec map. Returns the new map with
-/// results that referenced dim 0 dropped, and the axis positions in the index
-/// vec that need to be sliced.
-static AffineMap removeDim0FromIndexVecMap(AffineMap map,
-                                           SmallVectorImpl<int64_t> &axes) {
-  MLIRContext *ctx = map.getContext();
-  SmallVector<AffineExpr> newResults;
-  for (auto [resultIdx, expr] : llvm::enumerate(map.getResults())) {
-    if (auto dimExpr = dyn_cast<AffineDimExpr>(expr)) {
-      unsigned pos = dimExpr.getPosition();
-      if (pos == 0) {
-        axes.push_back(resultIdx);
-        continue; // Drop this result.
-      }
-      newResults.push_back(getAffineDimExpr(pos - 1, ctx));
-    } else {
-      newResults.push_back(expr);
-    }
-  }
-  return AffineMap::get(map.getNumDims() - 1, map.getNumSymbols(), newResults,
-                        ctx);
-}
-
-/// Extract a slice from a vector at position `idx` along the given `axis`.
-/// For a vector<4x8xindex>, extracting axis=0, idx=2 gives vector<8xindex>.
-static Value extractVecSlice(OpBuilder &b, Location loc, Value vec,
-                             int64_t axis, int64_t idx) {
-  auto vecType = cast<VectorType>(vec.getType());
-  int64_t rank = vecType.getRank();
-
-  if (axis == 0) {
-    // Extracting from rank-1 along axis 0 gives a scalar.
-    return vector::ExtractOp::create(b, loc, vec, SmallVector<int64_t>{idx});
-  }
-
-  // General case: use extract_strided_slice.
-  SmallVector<int64_t> offsets(rank, 0);
-  SmallVector<int64_t> sizes(vecType.getShape());
-  SmallVector<int64_t> strides(rank, 1);
-  offsets[axis] = idx;
-  sizes[axis] = 1;
-  Value slice = vector::ExtractStridedSliceOp::create(b, loc, vec, offsets,
-                                                      sizes, strides);
-  // Drop the unit dim.
-  SmallVector<int64_t> newShape;
-  for (int64_t i = 0; i < rank; ++i) {
-    if (i != axis) {
-      newShape.push_back(vecType.getShape()[i]);
-    }
-  }
-  auto newType = VectorType::get(newShape, vecType.getElementType());
-  return vector::ShapeCastOp::create(b, loc, newType, slice);
-}
-
-//===----------------------------------------------------------------------===//
-// UnrollTransferGatherDim
-//===----------------------------------------------------------------------===//
-
-/// Unrolls dim 0 of a transfer_gather, reducing vector rank by 1 each
-/// application. Stops at rank 1.
-struct UnrollTransferGatherDim : OpRewritePattern<TransferGatherOp> {
-  using Base::Base;
-
-  LogicalResult matchAndRewrite(TransferGatherOp op,
-                                PatternRewriter &rewriter) const override {
-    VectorType resultType = op.getVector().getType();
-    int64_t rank = resultType.getRank();
-    if (rank <= 1) {
-      return rewriter.notifyMatchFailure(op, "already rank <= 1");
-    }
-
-    Location loc = op.getLoc();
-    int64_t dim0Size = resultType.getShape()[0];
-    SmallVector<AffineMap> indexingMaps = op.getIndexingMapsArray();
-    AffineMap sourceMap = indexingMaps[0];
-    OperandRange indexVecs = op.getIndexVecs();
-    int64_t numIndexVecs = indexVecs.size();
-    Value mask = op.getMask();
-
-    // Compute the new source map (dim 0 removed).
-    AffineMap newSourceMap = removeDim0FromMap(sourceMap);
-
-    // For each index vec, compute how dim 0 removal affects it.
-    SmallVector<AffineMap> newIndexVecMaps;
-    SmallVector<SmallVector<int64_t>> indexVecAxes; // axes to slice per vec
-    for (int64_t i = 0; i < numIndexVecs; ++i) {
-      SmallVector<int64_t> axes;
-      AffineMap newMap = removeDim0FromIndexVecMap(indexingMaps[1 + i], axes);
-      newIndexVecMaps.push_back(newMap);
-      indexVecAxes.push_back(std::move(axes));
-    }
-
-    // Handle mask map.
-    AffineMap newMaskMap;
-    SmallVector<int64_t> maskAxes;
-    if (mask) {
-      newMaskMap = removeDim0FromIndexVecMap(indexingMaps.back(), maskAxes);
-    }
-
-    // Find which source dims use AffineDimExpr(0) — these need offset updates.
-    SmallVector<int64_t> sourceDimsUsingDim0;
-    for (auto [j, expr] : llvm::enumerate(sourceMap.getResults())) {
-      if (auto dimExpr = dyn_cast<AffineDimExpr>(expr)) {
-        if (dimExpr.getPosition() == 0) {
-          sourceDimsUsingDim0.push_back(j);
-        }
-      }
-    }
-
-    // Build the new result vector type (dim 0 removed).
-    SmallVector<int64_t> newShape(resultType.getShape().drop_front());
-    auto newResultType = VectorType::get(newShape, resultType.getElementType());
-
-    // Build new indexing_maps array.
-    SmallVector<AffineMap> newAllMaps;
-    newAllMaps.push_back(newSourceMap);
-    for (AffineMap &m : newIndexVecMaps) {
-      newAllMaps.push_back(m);
-    }
-    if (mask) {
-      newAllMaps.push_back(newMaskMap);
-    }
-
-    // Initialize accumulator.
-    Value acc = ub::PoisonOp::create(rewriter, loc, resultType);
-
-    for (int64_t i = 0; i < dim0Size; ++i) {
-      // Compute new base offsets.
-      SmallVector<Value> newOffsets(op.getOffsets());
-      for (int64_t srcDim : sourceDimsUsingDim0) {
-        Value offset = newOffsets[srcDim];
-        Value iVal = arith::ConstantIndexOp::create(rewriter, loc, i);
-        newOffsets[srcDim] = arith::AddIOp::create(rewriter, loc, offset, iVal);
-      }
-
-      // Extract index vec slices.
-      SmallVector<Value> newIndexVecs;
-      for (int64_t k = 0; k < numIndexVecs; ++k) {
-        Value idxVec = indexVecs[k];
-        if (indexVecAxes[k].empty()) {
-          // This index vec doesn't reference dim 0 — use as-is.
-          newIndexVecs.push_back(idxVec);
-        } else {
-          // Extract along each axis that referenced dim 0.
-          // Since maps only have simple dim exprs, there should be at most
-          // one axis referencing dim 0.
-          for (int64_t axis : indexVecAxes[k]) {
-            idxVec = extractVecSlice(rewriter, loc, idxVec, axis, i);
-          }
-          newIndexVecs.push_back(idxVec);
-        }
-      }
-
-      // Extract mask slice.
-      Value newMask;
-      if (mask) {
-        if (maskAxes.empty()) {
-          newMask = mask;
-        } else {
-          Value m = mask;
-          for (int64_t axis : maskAxes) {
-            m = extractVecSlice(rewriter, loc, m, axis, i);
-          }
-          newMask = m;
-        }
-      }
-
-      auto subGather = TransferGatherOp::create(
-          rewriter, loc, newResultType, op.getBase(), newOffsets, newIndexVecs,
-          rewriter.getAffineMapArrayAttr(newAllMaps), op.getPadding(), newMask);
-
-      // Insert into accumulator.
-      SmallVector<int64_t> offsets(rank, 0);
-      offsets[0] = i;
-      SmallVector<int64_t> strides(newShape.size(), 1);
-      acc = vector::InsertStridedSliceOp::create(
-          rewriter, loc, subGather.getResult(), acc, offsets, strides);
-    }
-
-    rewriter.replaceOp(op, acc);
-    return success();
-  }
-};
-
-} // namespace
-
-namespace mlir::iree_compiler::IREE::VectorExt {
-
-void populateVectorTransferGatherLoweringPatterns(RewritePatternSet &patterns) {
-  patterns.add<UnrollTransferGatherDim>(patterns.getContext());
-}
-
-} // namespace mlir::iree_compiler::IREE::VectorExt
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/LowerTransferGatherScatterOps.cpp b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/LowerTransferGatherScatterOps.cpp
new file mode 100644
index 0000000..c74bc88
--- /dev/null
+++ b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/LowerTransferGatherScatterOps.cpp
@@ -0,0 +1,314 @@
+// 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/Codegen/Dialect/VectorExt/IR/VectorExtOps.h"
+#include "iree/compiler/Codegen/Dialect/VectorExt/Transforms/Transforms.h"
+#include "mlir/Dialect/Arith/IR/Arith.h"
+#include "mlir/Dialect/UB/IR/UBOps.h"
+#include "mlir/Dialect/Vector/IR/VectorOps.h"
+#include "mlir/IR/AffineMap.h"
+#include "mlir/IR/PatternMatch.h"
+
+using namespace mlir;
+using namespace mlir::iree_compiler::IREE::VectorExt;
+
+namespace {
+
+/// Remove dim 0 from an AffineMap by:
+/// 1. Replacing AffineDimExpr(0) with AffineConstantExpr(0)
+/// 2. Renumbering AffineDimExpr(k) where k > 0 to AffineDimExpr(k-1)
+/// 3. Reducing numDims by 1
+static AffineMap removeDim0FromMap(AffineMap map) {
+  MLIRContext *ctx = map.getContext();
+  SmallVector<AffineExpr> newResults;
+  for (AffineExpr expr : map.getResults()) {
+    if (auto dimExpr = dyn_cast<AffineDimExpr>(expr)) {
+      unsigned pos = dimExpr.getPosition();
+      if (pos == 0) {
+        newResults.push_back(getAffineConstantExpr(0, ctx));
+      } else {
+        newResults.push_back(getAffineDimExpr(pos - 1, ctx));
+      }
+    } else {
+      newResults.push_back(expr);
+    }
+  }
+  return AffineMap::get(map.getNumDims() - 1, map.getNumSymbols(), newResults,
+                        ctx);
+}
+
+/// Remove dim 0 references from an index vec map. Returns the new map with
+/// results that referenced dim 0 dropped, and the axis positions in the index
+/// vec that need to be sliced.
+static AffineMap removeDim0FromIndexVecMap(AffineMap map,
+                                           SmallVectorImpl<int64_t> &axes) {
+  MLIRContext *ctx = map.getContext();
+  SmallVector<AffineExpr> newResults;
+  for (auto [resultIdx, expr] : llvm::enumerate(map.getResults())) {
+    if (auto dimExpr = dyn_cast<AffineDimExpr>(expr)) {
+      unsigned pos = dimExpr.getPosition();
+      if (pos == 0) {
+        axes.push_back(resultIdx);
+        continue;
+      }
+      newResults.push_back(getAffineDimExpr(pos - 1, ctx));
+    } else {
+      newResults.push_back(expr);
+    }
+  }
+  return AffineMap::get(map.getNumDims() - 1, map.getNumSymbols(), newResults,
+                        ctx);
+}
+
+/// Extract a slice from a vector at position `idx` along the given `axis`.
+/// For a vector<4x8xindex>, extracting axis=0, idx=2 gives vector<8xindex>.
+static Value extractVecSlice(OpBuilder &b, Location loc, Value vec,
+                             int64_t axis, int64_t idx) {
+  auto vecType = cast<VectorType>(vec.getType());
+  int64_t rank = vecType.getRank();
+
+  if (axis == 0) {
+    // Extracting from rank-1 along axis 0 gives a scalar.
+    return vector::ExtractOp::create(b, loc, vec, int64_t{idx});
+  }
+
+  // General case: use extract_strided_slice.
+  SmallVector<int64_t> offsets(rank, 0);
+  SmallVector<int64_t> sizes(vecType.getShape());
+  SmallVector<int64_t> strides(rank, 1);
+  offsets[axis] = idx;
+  sizes[axis] = 1;
+  Value slice = vector::ExtractStridedSliceOp::create(b, loc, vec, offsets,
+                                                      sizes, strides);
+  // Drop the unit dim.
+  SmallVector<int64_t> newShape;
+  for (int64_t i = 0; i < rank; ++i) {
+    if (i != axis) {
+      newShape.push_back(vecType.getShape()[i]);
+    }
+  }
+  auto newType = VectorType::get(newShape, vecType.getElementType());
+  return vector::ShapeCastOp::create(b, loc, newType, slice);
+}
+
+//===----------------------------------------------------------------------===//
+// Shared unroll helpers
+//===----------------------------------------------------------------------===//
+
+/// Compute dim-0-removed indexing maps for unrolling. Populates the new base
+/// map, per-index-vec maps and axes, mask map and axes, base dims using dim 0,
+/// and the combined new indexing maps array.
+static void
+computeUnrollDim0Maps(ArrayRef<AffineMap> indexingMaps, int64_t numIndexVecs,
+                      bool hasMask, AffineMap baseMap,
+                      SmallVectorImpl<AffineMap> &newAllMaps,
+                      SmallVectorImpl<SmallVector<int64_t>> &indexVecAxes,
+                      SmallVectorImpl<int64_t> &maskAxes,
+                      SmallVectorImpl<int64_t> &baseDimsUsingDim0) {
+  AffineMap newBaseMap = removeDim0FromMap(baseMap);
+  newAllMaps.push_back(newBaseMap);
+
+  for (int64_t i = 0; i < numIndexVecs; ++i) {
+    SmallVector<int64_t> axes;
+    AffineMap newMap = removeDim0FromIndexVecMap(indexingMaps[1 + i], axes);
+    newAllMaps.push_back(newMap);
+    indexVecAxes.push_back(std::move(axes));
+  }
+
+  if (hasMask) {
+    AffineMap newMaskMap =
+        removeDim0FromIndexVecMap(indexingMaps.back(), maskAxes);
+    newAllMaps.push_back(newMaskMap);
+  }
+
+  for (auto [j, expr] : llvm::enumerate(baseMap.getResults())) {
+    if (auto dimExpr = dyn_cast<AffineDimExpr>(expr)) {
+      if (dimExpr.getPosition() == 0) {
+        baseDimsUsingDim0.push_back(j);
+      }
+    }
+  }
+}
+
+/// Extract sliced index vecs and mask for iteration `i` of dim-0 unrolling.
+/// Returns the sliced mask (or nullptr if no mask).
+static Value
+extractSlicesForIteration(OpBuilder &rewriter, Location loc, int64_t i,
+                          OperandRange indexVecs, int64_t numIndexVecs,
+                          ArrayRef<SmallVector<int64_t>> indexVecAxes,
+                          Value mask, ArrayRef<int64_t> maskAxes,
+                          SmallVectorImpl<Value> &newIndexVecs) {
+  for (int64_t k = 0; k < numIndexVecs; ++k) {
+    Value idxVec = indexVecs[k];
+    if (!indexVecAxes[k].empty()) {
+      for (int64_t axis : indexVecAxes[k]) {
+        idxVec = extractVecSlice(rewriter, loc, idxVec, axis, i);
+      }
+    }
+    newIndexVecs.push_back(idxVec);
+  }
+
+  if (!mask) {
+    return nullptr;
+  }
+  Value m = mask;
+  for (int64_t axis : maskAxes) {
+    m = extractVecSlice(rewriter, loc, m, axis, i);
+  }
+  return m;
+}
+
+/// Update base offsets for dim-0 iteration `i`.
+static SmallVector<Value>
+computeNewOffsets(OpBuilder &rewriter, Location loc, ValueRange offsets,
+                  int64_t i, ArrayRef<int64_t> baseDimsUsingDim0) {
+  SmallVector<Value> newOffsets(offsets);
+  for (int64_t baseDim : baseDimsUsingDim0) {
+    Value offset = newOffsets[baseDim];
+    Value iVal = arith::ConstantIndexOp::create(rewriter, loc, i);
+    newOffsets[baseDim] = arith::AddIOp::create(rewriter, loc, offset, iVal);
+  }
+  return newOffsets;
+}
+
+//===----------------------------------------------------------------------===//
+// UnrollTransferGatherDim / UnrollTransferScatterDim
+//===----------------------------------------------------------------------===//
+
+/// Unrolls dim 0 of a transfer_gather, reducing vector rank by 1 each
+/// application. Sub-gathers are assembled into the result via
+/// insert_strided_slice. Stops at rank 1.
+struct UnrollTransferGatherDim final : OpRewritePattern<TransferGatherOp> {
+  using Base::Base;
+
+  LogicalResult matchAndRewrite(TransferGatherOp op,
+                                PatternRewriter &rewriter) const override {
+    VectorType vectorType = op.getVector().getType();
+    int64_t rank = vectorType.getRank();
+    if (rank <= 1) {
+      return rewriter.notifyMatchFailure(op, "already rank <= 1");
+    }
+
+    Location loc = op.getLoc();
+    int64_t dim0Size = vectorType.getShape()[0];
+    SmallVector<AffineMap> indexingMaps = op.getIndexingMapsArray();
+    OperandRange indexVecs = op.getIndexVecs();
+    int64_t numIndexVecs = indexVecs.size();
+    Value mask = op.getMask();
+
+    SmallVector<AffineMap> newAllMaps;
+    SmallVector<SmallVector<int64_t>> indexVecAxes;
+    SmallVector<int64_t> maskAxes, baseDimsUsingDim0;
+    computeUnrollDim0Maps(indexingMaps, numIndexVecs, !!mask, indexingMaps[0],
+                          newAllMaps, indexVecAxes, maskAxes,
+                          baseDimsUsingDim0);
+
+    SmallVector<int64_t> newShape(vectorType.getShape().drop_front());
+    auto newVectorType = VectorType::get(newShape, vectorType.getElementType());
+
+    Value acc = ub::PoisonOp::create(rewriter, loc, vectorType);
+
+    for (int64_t i = 0; i < dim0Size; ++i) {
+      SmallVector<Value> newOffsets = computeNewOffsets(
+          rewriter, loc, op.getOffsets(), i, baseDimsUsingDim0);
+
+      SmallVector<Value> newIndexVecs;
+      Value newMask =
+          extractSlicesForIteration(rewriter, loc, i, indexVecs, numIndexVecs,
+                                    indexVecAxes, mask, maskAxes, newIndexVecs);
+
+      auto subGather = TransferGatherOp::create(
+          rewriter, loc, newVectorType, op.getBase(), newOffsets, newIndexVecs,
+          rewriter.getAffineMapArrayAttr(newAllMaps), op.getPadding(), newMask);
+
+      SmallVector<int64_t> offsets(rank, 0);
+      offsets[0] = i;
+      SmallVector<int64_t> strides(newShape.size(), 1);
+      acc = vector::InsertStridedSliceOp::create(
+          rewriter, loc, subGather.getResult(), acc, offsets, strides);
+    }
+
+    rewriter.replaceOp(op, acc);
+    return success();
+  }
+};
+
+/// Unrolls dim 0 of a transfer_scatter, reducing vector rank by 1 each
+/// application. For tensor semantics, sub-scatters are chained via SSA
+/// results. For memref semantics, sub-scatters write in-place. Stops at
+/// rank 1.
+struct UnrollTransferScatterDim final : OpRewritePattern<TransferScatterOp> {
+  using Base::Base;
+
+  LogicalResult matchAndRewrite(TransferScatterOp op,
+                                PatternRewriter &rewriter) const override {
+    VectorType vectorType = op.getVectorType();
+    int64_t rank = vectorType.getRank();
+    if (rank <= 1) {
+      return rewriter.notifyMatchFailure(op, "already rank <= 1");
+    }
+
+    Location loc = op.getLoc();
+    int64_t dim0Size = vectorType.getShape()[0];
+    SmallVector<AffineMap> indexingMaps = op.getIndexingMapsArray();
+    OperandRange indexVecs = op.getIndexVecs();
+    int64_t numIndexVecs = indexVecs.size();
+    Value mask = op.getMask();
+
+    SmallVector<AffineMap> newAllMaps;
+    SmallVector<SmallVector<int64_t>> indexVecAxes;
+    SmallVector<int64_t> maskAxes, baseDimsUsingDim0;
+    computeUnrollDim0Maps(indexingMaps, numIndexVecs, !!mask, indexingMaps[0],
+                          newAllMaps, indexVecAxes, maskAxes,
+                          baseDimsUsingDim0);
+
+    Value dest = op.getBase();
+
+    for (int64_t i = 0; i < dim0Size; ++i) {
+      SmallVector<Value> newOffsets = computeNewOffsets(
+          rewriter, loc, op.getOffsets(), i, baseDimsUsingDim0);
+
+      SmallVector<Value> newIndexVecs;
+      Value newMask =
+          extractSlicesForIteration(rewriter, loc, i, indexVecs, numIndexVecs,
+                                    indexVecAxes, mask, maskAxes, newIndexVecs);
+
+      Value vecSlice =
+          vector::ExtractOp::create(rewriter, loc, op.getVector(), int64_t{i});
+
+      if (op.hasTensorSemantics()) {
+        auto subScatter = TransferScatterOp::create(
+            rewriter, loc, dest.getType(), dest, vecSlice, newOffsets,
+            newIndexVecs, rewriter.getAffineMapArrayAttr(newAllMaps), newMask);
+        dest = subScatter.getResult();
+        continue;
+      }
+      TransferScatterOp::create(rewriter, loc, /*resultTypes=*/TypeRange{},
+                                dest, vecSlice, newOffsets, newIndexVecs,
+                                rewriter.getAffineMapArrayAttr(newAllMaps),
+                                newMask);
+    }
+
+    if (op.hasTensorSemantics()) {
+      rewriter.replaceOp(op, dest);
+    } else {
+      rewriter.eraseOp(op);
+    }
+    return success();
+  }
+};
+
+} // namespace
+
+namespace mlir::iree_compiler::IREE::VectorExt {
+
+void populateVectorTransferGatherScatterLoweringPatterns(
+    RewritePatternSet &patterns) {
+  patterns.add<UnrollTransferGatherDim, UnrollTransferScatterDim>(
+      patterns.getContext());
+}
+
+} // namespace mlir::iree_compiler::IREE::VectorExt
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/Transforms.h b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/Transforms.h
index d533e42..c15f2e7 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/Transforms.h
+++ b/compiler/src/iree/compiler/Codegen/Dialect/VectorExt/Transforms/Transforms.h
@@ -11,7 +11,8 @@
 
 namespace mlir::iree_compiler::IREE::VectorExt {
 
-void populateVectorTransferGatherLoweringPatterns(RewritePatternSet &patterns);
+void populateVectorTransferGatherScatterLoweringPatterns(
+    RewritePatternSet &patterns);
 
 }; // namespace mlir::iree_compiler::IREE::VectorExt
 
diff --git a/compiler/src/iree/compiler/Codegen/LLVMGPU/LLVMGPUVectorLowering.cpp b/compiler/src/iree/compiler/Codegen/LLVMGPU/LLVMGPUVectorLowering.cpp
index ebdb9b4..b8e7c53 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMGPU/LLVMGPUVectorLowering.cpp
+++ b/compiler/src/iree/compiler/Codegen/LLVMGPU/LLVMGPUVectorLowering.cpp
@@ -617,12 +617,14 @@
           contractLoweringPatterns,
           vector::VectorMultiReductionLowering::InnerReduction);
       contractLoweringPatterns.add<UnrollElementwiseOps>(funcOp->getContext());
-      // Unroll transfer_gather ops to rank 1 and lower contiguous ones to
-      // vector.transfer_read.
-      IREE::VectorExt::populateVectorTransferGatherLoweringPatterns(
+      // Unroll transfer_gather/scatter ops to rank 1 and lower contiguous ones
+      // to vector.transfer_read/write.
+      IREE::VectorExt::populateVectorTransferGatherScatterLoweringPatterns(
           contractLoweringPatterns);
       IREE::VectorExt::TransferGatherOp::getCanonicalizationPatterns(
           contractLoweringPatterns, ctx);
+      IREE::VectorExt::TransferScatterOp::getCanonicalizationPatterns(
+          contractLoweringPatterns, ctx);
       if (failed(applyPatternsGreedily(funcOp,
                                        std::move(contractLoweringPatterns)))) {
         return signalPassFailure();
diff --git a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/vector_lowering.mlir b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/vector_lowering.mlir
index 4519a73..f8727cc 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/vector_lowering.mlir
+++ b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/vector_lowering.mlir
@@ -300,3 +300,95 @@
 // CHECK-NOT: transfer_gather
 // CHECK-COUNT-32: vector.load
 // CHECK-NOT: transfer_gather
+
+// -----
+
+// Test unrolling of a 2D transfer_scatter: outer dim is scattered (indices),
+// inner dim is contiguous.
+
+func.func @transfer_scatter_unroll_embedding_write(
+  %source: memref<4096x64xf16>,
+  %vector: vector<4x64xf16>,
+  %indices: vector<4xindex>) {
+  %c0 = arith.constant 0 : index
+  iree_vector_ext.transfer_scatter %vector into %source[%c0, %c0]
+  [%indices : vector<4xindex>] {
+    indexing_maps = [affine_map<(d0, d1)[s0] -> (s0, d1)>,
+                     affine_map<(d0, d1)[s0] -> (d0)>]
+  } : vector<4x64xf16>, memref<4096x64xf16>
+  return
+}
+
+// After unrolling, the 2D scatter becomes 4 rank-1 sub-scatters.
+// CHECK-LABEL: func.func @transfer_scatter_unroll_embedding_write
+// CHECK-COUNT-4: transfer_scatter {{.+}} : vector<64xf16>
+
+// -----
+
+// Test unrolling of a masked 2D transfer_scatter.
+
+func.func @transfer_scatter_unroll_masked(
+  %source: memref<4096x64xf16>,
+  %vector: vector<4x64xf16>,
+  %indices: vector<4xindex>,
+  %mask: vector<4x64xi1>) {
+  %c0 = arith.constant 0 : index
+  iree_vector_ext.transfer_scatter %vector into %source[%c0, %c0]
+  [%indices : vector<4xindex>], %mask {
+    indexing_maps = [affine_map<(d0, d1)[s0] -> (s0, d1)>,
+                     affine_map<(d0, d1)[s0] -> (d0)>,
+                     affine_map<(d0, d1)[s0] -> (d0, d1)>]
+  } : vector<4x64xf16>, memref<4096x64xf16>, vector<4x64xi1>
+  return
+}
+
+// After unrolling, mask slices are passed to each sub-scatter.
+// CHECK-LABEL: func.func @transfer_scatter_unroll_masked
+// CHECK-COUNT-4: transfer_scatter {{.+}} : vector<64xf16>
+
+// -----
+
+// Test unrolling of a 2D transfer_scatter with tensor semantics.
+
+func.func @transfer_scatter_unroll_tensor(
+  %dest: tensor<4096x64xf16>,
+  %vector: vector<4x64xf16>,
+  %indices: vector<4xindex>) -> tensor<4096x64xf16> {
+  %c0 = arith.constant 0 : index
+  %out = iree_vector_ext.transfer_scatter %vector into %dest[%c0, %c0]
+  [%indices : vector<4xindex>] {
+    indexing_maps = [affine_map<(d0, d1)[s0] -> (s0, d1)>,
+                     affine_map<(d0, d1)[s0] -> (d0)>]
+  } : vector<4x64xf16>, tensor<4096x64xf16> -> tensor<4096x64xf16>
+  return %out : tensor<4096x64xf16>
+}
+
+// After unrolling, the 2D scatter becomes 4 rank-1 sub-scatters chained
+// via tensor SSA results.
+// CHECK-LABEL: func.func @transfer_scatter_unroll_tensor
+// CHECK-COUNT-4: transfer_scatter {{.+}} : vector<64xf16>
+
+// -----
+
+// Test unrolling of a 3D transfer_scatter with a transposed 2D index vector.
+// The first two output dims (d0=4, d1=8) are both scattered via a single
+// index vec of shape 8x4 (note: d1 before d0, i.e. "transposed").
+// The inner dim (d2=64) is contiguous.
+
+func.func @transfer_scatter_unroll_transposed_index(
+  %dest: memref<4096x64xf16>,
+  %vector: vector<4x8x64xf16>,
+  %indices: vector<8x4xindex>) {
+  %c0 = arith.constant 0 : index
+  iree_vector_ext.transfer_scatter %vector into %dest[%c0, %c0]
+  [%indices : vector<8x4xindex>] {
+    indexing_maps = [affine_map<(d0, d1, d2)[s0] -> (s0, d2)>,
+                     affine_map<(d0, d1, d2)[s0] -> (d1, d0)>]
+  } : vector<4x8x64xf16>, memref<4096x64xf16>
+  return
+}
+
+// After two rounds of unrolling (d0=4 then d1=8), the 3D scatter
+// becomes 4*8=32 rank-1 sub-scatters.
+// CHECK-LABEL: func.func @transfer_scatter_unroll_transposed_index
+// CHECK-COUNT-32: transfer_scatter {{.+}} : vector<64xf16>