[Codegen] Materialize encoding info for convolution with NCHWc layout (#24714)

Implements convolution encoding materialization for data-tiled layouts.

Layouts:
- Input:  [N, IC/c0, H, W, c0]
- Filter: [OC/k0, IC/c0, FH, FW, c0, k0]
- Output: [N, OC/k0, OH, OW, k0]

where k0 and c0 are the inner tile sizes for the output and input
channels, respectively.

Scope:

- Only 2D convolutions receive a data-tiled layout. 
- Grouped, depthwise, 1D, and 3D convolutions, and non-unit dilations,
all materialize to the identity layout (existing un-tiled path, no
behavior change for these cases).
   - Arbitrary strides are supported.
- Supports packed rank-5 input/output and rank-6 filter tensors. Rank-4
input/output is also accepted when `N=1` batch dimension has been folded
by upstream passes.
- Currently supports fp32 convolutions only.

Supported input forms:
- nhwc_hwcf
- nchw_fchw
- nhwc_fhwc

All are materialized to the same packed NCHWc layout above.

Architecture-specific inner tiles:

| Architecture | Condition | Inner tile (OC, IC) | Target ISA |

|--------------|-------------------|---------------------|-----------------|
| x86-64 | float, `+avx512f` | `{16,16}` | `VFMADD*` (zmm) |
| arm64 | float | `{8,8}` | `FMLA` (NEON) |

All other configurations materialize to the identity layout.

Layout motivation can be found here:
https://hackmd.io/@phemashekar/conv-dt-layout

---

Co-authored-by: Jelle Schuhmacher <schuehmacher@roofline.ai>
Co-authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Pooja Hemashekar <hemashekar@roofline.ai>

---------

Signed-off-by: Pooja Hemashekar <hemashekar@roofline.ai>
Co-authored-by: Jelle Schuhmacher <schuehmacher@roofline.ai>
Co-authored-by: Ege Beysel <beyselege@gmail.com>
diff --git a/compiler/src/iree/compiler/Codegen/Common/test/BUILD.bazel b/compiler/src/iree/compiler/Codegen/Common/test/BUILD.bazel
index dbb4c96..fbc1e5a 100644
--- a/compiler/src/iree/compiler/Codegen/Common/test/BUILD.bazel
+++ b/compiler/src/iree/compiler/Codegen/Common/test/BUILD.bazel
@@ -88,6 +88,8 @@
             "lower_ukernel_to_calls.mlir",
             "lowering_config_interpreter.mlir",
             "materialize_encoding_aarch64.mlir",
+            "materialize_encoding_conv_aarch64.mlir",
+            "materialize_encoding_conv_x86_64.mlir",
             "materialize_encoding_for_iree_ops.mlir",
             "materialize_encoding_gfx1100.mlir",
             "materialize_encoding_gfx908.mlir",
diff --git a/compiler/src/iree/compiler/Codegen/Common/test/CMakeLists.txt b/compiler/src/iree/compiler/Codegen/Common/test/CMakeLists.txt
index c950ca2..f3220e2 100644
--- a/compiler/src/iree/compiler/Codegen/Common/test/CMakeLists.txt
+++ b/compiler/src/iree/compiler/Codegen/Common/test/CMakeLists.txt
@@ -83,6 +83,8 @@
     "lower_ukernel_to_calls.mlir"
     "lowering_config_interpreter.mlir"
     "materialize_encoding_aarch64.mlir"
+    "materialize_encoding_conv_aarch64.mlir"
+    "materialize_encoding_conv_x86_64.mlir"
     "materialize_encoding_for_iree_ops.mlir"
     "materialize_encoding_gfx1100.mlir"
     "materialize_encoding_gfx908.mlir"
diff --git a/compiler/src/iree/compiler/Codegen/Common/test/materialize_encoding_conv_aarch64.mlir b/compiler/src/iree/compiler/Codegen/Common/test/materialize_encoding_conv_aarch64.mlir
new file mode 100644
index 0000000..48e00bc
--- /dev/null
+++ b/compiler/src/iree/compiler/Codegen/Common/test/materialize_encoding_conv_aarch64.mlir
@@ -0,0 +1,574 @@
+// RUN: iree-opt --pass-pipeline="builtin.module(func.func(iree-codegen-materialize-device-encoding))" --split-input-file %s | FileCheck %s
+
+// Map invariant materialization (all input formats must produce the same canonical generics):
+//   input:   (d0..d8) -> (d0, d4, d2 + d5, d3 + d6, d8)
+//   filter:  (d0..d8) -> (d1, d4, d5, d6, d8, d7)
+//   output:  (d0..d8) -> (d0, d1, d2, d3, d7)
+
+// conv_2d_nhwc_hwcf materialization.
+
+// Pack input from [N, H, W, C] -> [N, IC/c0, H, W, c0].
+
+#nhwc_hwcf_map_in  = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (n, oh + fh, ow + fw, ic)>
+#nhwc_hwcf_map_f   = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (fh, fw, ic, oc)>
+#nhwc_hwcf_map_out = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (n, oh, ow, oc)>
+
+// CHECK: #[[$M_IN:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d4, d2 + d5, d3 + d6, d8)>
+// CHECK: #[[$M_FLT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d1, d4, d5, d6, d8, d7)>
+// CHECK: #[[$M_OUT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d1, d2, d3, d7)>
+
+#encoding_nhwc_hwcf_input = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_input_pack(%arg0: tensor<1x16x16x4xf32>)
+    -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+  return %0 : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+}
+// CHECK-LABEL: func.func @conv_input_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x16x16x4xf32> -> tensor<1x1x16x16x8xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Pack filter from [KH, KW, C, F] -> [OC/k0, IC/c0, KH, KW, c0, k0].
+
+#encoding_nhwc_hwcf_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_filter_pack(%arg0: tensor<3x3x4x8xf32>)
+    -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+  return %0 : tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+}
+// CHECK-LABEL: func.func @conv_filter_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [3, 2, 0, 1]
+// CHECK-SAME:      inner_dims_pos = [2, 3]
+// CHECK-SAME:      inner_tiles = [8, 8]
+// CHECK-SAME:      : tensor<3x3x4x8xf32> -> tensor<1x1x3x3x8x8xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Unpack output from [N, OC/k0, OH, OW, k0] -> [N, OH, OW, OC].
+
+#encoding_nhwc_hwcf_output = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_output_unset(%arg0: tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+    -> tensor<1x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.unset_encoding %arg0
+       : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output> -> tensor<1x14x14x8xf32>
+  return %0 : tensor<1x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv_output_unset
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x1x14x14x8xf32>
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x1x14x14x8xf32> -> tensor<1x14x14x8xf32>
+// CHECK:         return %[[UNPACK]]
+
+
+// Conv materialization.
+
+func.func @conv2d_nhwc_hwcf_materialize(
+    %input  : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+    %filter : tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>,
+    %output : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+    -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = linalg.conv_2d_nhwc_hwcf
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%input, %filter
+           : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+             tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>)
+         outs(%output : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+         -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  return %0 : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+}
+// CHECK-LABEL: func.func @conv2d_nhwc_hwcf_materialize
+// CHECK-SAME:    %[[INPUT:.+]]: tensor<1x1x16x16x8xf32>
+// CHECK-SAME:    %[[FILTER:.+]]: tensor<1x1x3x3x8x8xf32>
+// CHECK-SAME:    %[[OUTPUT:.+]]: tensor<1x1x14x14x8xf32>
+//
+// 9D data-tiled conv generic with three operands (input, filter, output):
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      indexing_maps = [#[[$M_IN]], #[[$M_FLT]], #[[$M_OUT]]]
+// CHECK-SAME:      iterator_types = ["parallel", "parallel", "parallel", "parallel",
+// CHECK-SAME:                        "reduction", "reduction", "reduction",
+// CHECK-SAME:                        "parallel", "reduction"]
+// CHECK-SAME:      ins(%[[INPUT]], %[[FILTER]]
+// CHECK-SAME:      outs(%[[OUTPUT]]
+// CHECK:           %[[MUL:.+]] = arith.mulf
+// CHECK:           %[[ADD:.+]] = arith.addf %[[MUL]]
+// CHECK:           linalg.yield %[[ADD]]
+// CHECK:         return %[[RESULT]]
+
+
+// Full conv materialization.
+
+func.func @conv2d_nhwc_hwcf_materialize_full(
+    %input  : tensor<1x16x16x4xf32>,
+    %filter : tensor<3x3x4x8xf32>,
+    %output : tensor<1x14x14x8xf32>)
+    -> tensor<1x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %enc_in  = iree_encoding.set_encoding %input
+      : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+  %enc_f   = iree_encoding.set_encoding %filter
+      : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+  %enc_out = iree_encoding.set_encoding %output
+      : tensor<1x14x14x8xf32> -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %0 = linalg.conv_2d_nhwc_hwcf
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%enc_in, %enc_f
+           : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+             tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>)
+         outs(%enc_out : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+         -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output> -> tensor<1x14x14x8xf32>
+  return %1 : tensor<1x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv2d_nhwc_hwcf_materialize_full
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK-SAME:    %[[F:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x14x14x8xf32>
+//
+// CHECK:         %[[PACK_IN:.+]] = linalg.pack %[[IN]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x16x16x4xf32> -> tensor<1x1x16x16x8xf32>
+//
+// CHECK:         %[[PACK_F:.+]] = linalg.pack %[[F]]
+// CHECK-SAME:      outer_dims_perm = [3, 2, 0, 1]
+// CHECK-SAME:      inner_dims_pos = [2, 3]
+// CHECK-SAME:      inner_tiles = [8, 8]
+// CHECK-SAME:      : tensor<3x3x4x8xf32> -> tensor<1x1x3x3x8x8xf32>
+//
+// CHECK:         %[[PACK_OUT:.+]] = linalg.pack %[[OUT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x14x14x8xf32> -> tensor<1x1x14x14x8xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      ins(%[[PACK_IN]], %[[PACK_F]]
+// CHECK-SAME:      outs(%[[PACK_OUT]]
+//
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[RESULT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x1x14x14x8xf32> -> tensor<1x14x14x8xf32>
+
+
+// NHWC-HWCF conv expressed as a linalg.generic carrying conv encodings.
+// It must lower to the same pack / 9D generic / unpack as the named conv2d_nhwc_hwcf_materialize_full above.
+
+func.func @conv2d_nhwc_hwcf_materialize_generic(
+    %input  : tensor<1x16x16x4xf32>,
+    %filter : tensor<3x3x4x8xf32>,
+    %output : tensor<1x14x14x8xf32>)
+    -> tensor<1x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %enc_in  = iree_encoding.set_encoding %input
+      : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+  %enc_f   = iree_encoding.set_encoding %filter
+      : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+  %enc_out = iree_encoding.set_encoding %output
+      : tensor<1x14x14x8xf32> -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %0 = linalg.generic
+         {indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+          iterator_types = ["parallel", "parallel", "parallel", "parallel",
+                            "reduction", "reduction", "reduction"]}
+         ins(%enc_in, %enc_f
+           : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+             tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>)
+         outs(%enc_out : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>) {
+  ^bb0(%in: f32, %f: f32, %acc: f32):
+    %m = arith.mulf %in, %f : f32
+    %a = arith.addf %acc, %m : f32
+    linalg.yield %a : f32
+  } -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output> -> tensor<1x14x14x8xf32>
+  return %1 : tensor<1x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv2d_nhwc_hwcf_materialize_generic
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK-SAME:    %[[F:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x14x14x8xf32>
+//
+// CHECK:         %[[PACK_IN:.+]] = linalg.pack %[[IN]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x16x16x4xf32> -> tensor<1x1x16x16x8xf32>
+//
+// CHECK:         %[[PACK_F:.+]] = linalg.pack %[[F]]
+// CHECK-SAME:      outer_dims_perm = [3, 2, 0, 1]
+// CHECK-SAME:      inner_dims_pos = [2, 3]
+// CHECK-SAME:      inner_tiles = [8, 8]
+// CHECK-SAME:      : tensor<3x3x4x8xf32> -> tensor<1x1x3x3x8x8xf32>
+//
+// CHECK:         %[[PACK_OUT:.+]] = linalg.pack %[[OUT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x14x14x8xf32> -> tensor<1x1x14x14x8xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      ins(%[[PACK_IN]], %[[PACK_F]]
+// CHECK-SAME:      outs(%[[PACK_OUT]]
+//
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[RESULT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x1x14x14x8xf32> -> tensor<1x14x14x8xf32>
+
+
+// -----
+
+// conv_2d_nchw_fchw materialization.
+
+// Pack input from [N, C, H, W] -> [N, IC/c0, H, W, c0].
+
+#nchw_fchw_map_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d4, d2 + d5, d3 + d6)>
+#nchw_fchw_map_f   = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d1, d4, d5, d6)>
+#nchw_fchw_map_out = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>
+
+// CHECK: #[[$M_IN:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d4, d2 + d5, d3 + d6, d8)>
+// CHECK: #[[$M_FLT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d1, d4, d5, d6, d8, d7)>
+// CHECK: #[[$M_OUT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d1, d2, d3, d7)>
+
+#encoding_nhwc_fchw_input = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nchw_fchw_map_in, #nchw_fchw_map_f, #nchw_fchw_map_out],
+  iteration_sizes = [1, 8, 14, 14, 4, 3, 3]>
+
+func.func @conv_nchw_input_pack(%arg0: tensor<1x4x16x16xf32>)
+    -> tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<1x4x16x16xf32> -> tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>
+  return %0 : tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>
+}
+// CHECK-LABEL: func.func @conv_nchw_input_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x4x16x16xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x4x16x16xf32> -> tensor<1x1x16x16x8xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Pack filter from [F, C, KH, KW] -> [OC/k0, IC/c0, KH, KW, c0, k0].
+
+#encoding_nhwc_fchw_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nchw_fchw_map_in, #nchw_fchw_map_f, #nchw_fchw_map_out],
+  iteration_sizes = [1, 8, 14, 14, 4, 3, 3]>
+
+func.func @conv_fchw_filter_pack(%arg0: tensor<8x4x3x3xf32>)
+    -> tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<8x4x3x3xf32> -> tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>
+  return %0 : tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>
+}
+// CHECK-LABEL: func.func @conv_fchw_filter_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<8x4x3x3xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1, 0]
+// CHECK-SAME:      inner_tiles = [8, 8]
+// CHECK-SAME:      : tensor<8x4x3x3xf32> -> tensor<1x1x3x3x8x8xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Unpack output from [N, OC/k0, OH, OW, k0] -> [N, OC, OH, OW].
+
+#encoding_nhwc_fchw_output = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nchw_fchw_map_in, #nchw_fchw_map_f, #nchw_fchw_map_out],
+  iteration_sizes = [1, 8, 14, 14, 4, 3, 3]>
+
+func.func @conv_nchw_output_unset(%arg0: tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>)
+    -> tensor<1x8x14x14xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.unset_encoding %arg0
+       : tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output> -> tensor<1x8x14x14xf32>
+  return %0 : tensor<1x8x14x14xf32>
+}
+// CHECK-LABEL: func.func @conv_nchw_output_unset
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x1x14x14x8xf32>
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x1x14x14x8xf32> -> tensor<1x8x14x14xf32>
+// CHECK:         return %[[UNPACK]]
+
+
+// Conv materialization.
+
+func.func @conv2d_nchw_fchw_materialize(
+    %input  : tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>,
+    %filter : tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>,
+    %output : tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>)
+    -> tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = linalg.conv_2d_nchw_fchw
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%input, %filter
+           : tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>,
+             tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>)
+         outs(%output : tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>)
+         -> tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>
+  return %0 : tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>
+}
+// CHECK-LABEL: func.func @conv2d_nchw_fchw_materialize
+// CHECK-SAME:    %[[INPUT:.+]]: tensor<1x1x16x16x8xf32>
+// CHECK-SAME:    %[[FILTER:.+]]: tensor<1x1x3x3x8x8xf32>
+// CHECK-SAME:    %[[OUTPUT:.+]]: tensor<1x1x14x14x8xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      indexing_maps = [#[[$M_IN]], #[[$M_FLT]], #[[$M_OUT]]]
+// CHECK-SAME:      iterator_types = ["parallel", "parallel", "parallel", "parallel",
+// CHECK-SAME:                        "reduction", "reduction", "reduction",
+// CHECK-SAME:                        "parallel", "reduction"]
+// CHECK-SAME:      ins(%[[INPUT]], %[[FILTER]]
+// CHECK-SAME:      outs(%[[OUTPUT]]
+// CHECK:           %[[MUL:.+]] = arith.mulf
+// CHECK:           %[[ADD:.+]] = arith.addf %[[MUL]]
+// CHECK:           linalg.yield %[[ADD]]
+// CHECK:         return %[[RESULT]]
+
+
+// Full conv materialization.
+
+func.func @conv2d_nchw_fchw_materialize_full(
+    %input  : tensor<1x4x16x16xf32>,
+    %filter : tensor<8x4x3x3xf32>,
+    %output : tensor<1x8x14x14xf32>)
+    -> tensor<1x8x14x14xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %enc_in  = iree_encoding.set_encoding %input
+      : tensor<1x4x16x16xf32> -> tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>
+  %enc_f   = iree_encoding.set_encoding %filter
+      : tensor<8x4x3x3xf32> -> tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>
+  %enc_out = iree_encoding.set_encoding %output
+      : tensor<1x8x14x14xf32> -> tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>
+  %0 = linalg.conv_2d_nchw_fchw
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%enc_in, %enc_f
+           : tensor<1x4x16x16xf32, #encoding_nhwc_fchw_input>,
+             tensor<8x4x3x3xf32, #encoding_nhwc_fchw_filter>)
+         outs(%enc_out : tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>)
+         -> tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x8x14x14xf32, #encoding_nhwc_fchw_output> -> tensor<1x8x14x14xf32>
+  return %1 : tensor<1x8x14x14xf32>
+}
+// CHECK-LABEL: func.func @conv2d_nchw_fchw_materialize_full
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x4x16x16xf32>
+// CHECK-SAME:    %[[F:[a-zA-Z0-9]+]]: tensor<8x4x3x3xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x8x14x14xf32>
+//
+// CHECK:         %[[PACK_IN:.+]] = linalg.pack %[[IN]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x4x16x16xf32> -> tensor<1x1x16x16x8xf32>
+//
+// CHECK:         %[[PACK_F:.+]] = linalg.pack %[[F]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1, 0]
+// CHECK-SAME:      inner_tiles = [8, 8]
+// CHECK-SAME:      : tensor<8x4x3x3xf32> -> tensor<1x1x3x3x8x8xf32>
+//
+// CHECK:         %[[PACK_OUT:.+]] = linalg.pack %[[OUT]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x8x14x14xf32> -> tensor<1x1x14x14x8xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      ins(%[[PACK_IN]], %[[PACK_F]]
+// CHECK-SAME:      outs(%[[PACK_OUT]]
+//
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[RESULT]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [8]
+// CHECK-SAME:      : tensor<1x1x14x14x8xf32> -> tensor<1x8x14x14xf32>
+
+// -----
+
+// conv_2d_nhwc_fhwc materialization.
+
+// Pack filter from [F, KH, KW, C] -> [OC/k0, IC/c0, KH, KW, c0, k0].
+
+#nhwc_fhwc_map_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1 + d4, d2 + d5, d6)>
+#nhwc_fhwc_map_f   = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d3, d4, d5, d6)>
+#nhwc_fhwc_map_out = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>
+
+#encoding_nhwc_fhwc_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_fhwc_map_in, #nhwc_fhwc_map_f, #nhwc_fhwc_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_fhwc_filter_pack(%arg0: tensor<8x3x3x4xf32>)
+    -> tensor<8x3x3x4xf32, #encoding_nhwc_fhwc_filter>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<8x3x3x4xf32> -> tensor<8x3x3x4xf32, #encoding_nhwc_fhwc_filter>
+  return %0 : tensor<8x3x3x4xf32, #encoding_nhwc_fhwc_filter>
+}
+// CHECK-LABEL: func.func @conv_fhwc_filter_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<8x3x3x4xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3, 0]
+// CHECK-SAME:      inner_tiles = [8, 8]
+// CHECK-SAME:      : tensor<8x3x3x4xf32> -> tensor<1x1x3x3x8x8xf32>
+// CHECK:         return %[[PACK]]
+
+// -----
+
+// Unit batch-dim folded conv: N=1 stripped upstream, so operands pack to rank-4
+// and the lowering restores the batch via expand/collapse around the 9-D generic.
+
+#bl_map_in  = affine_map<(oh, ow, oc, fh, fw, ic) -> (oh + fh, ow + fw, ic)>
+#bl_map_f   = affine_map<(oh, ow, oc, fh, fw, ic) -> (fh, fw, ic, oc)>
+#bl_map_out = affine_map<(oh, ow, oc, fh, fw, ic) -> (oh, ow, oc)>
+
+#encoding_bl_input = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+  iteration_sizes = [14, 14, 8, 3, 3, 4]>
+#encoding_bl_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+  iteration_sizes = [14, 14, 8, 3, 3, 4]>
+#encoding_bl_output = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+  iteration_sizes = [14, 14, 8, 3, 3, 4]>
+
+func.func @conv2d_batchless_materialize(
+    %input  : tensor<16x16x4xf32, #encoding_bl_input>,
+    %filter : tensor<3x3x4x8xf32, #encoding_bl_filter>,
+    %output : tensor<14x14x8xf32, #encoding_bl_output>)
+    -> tensor<14x14x8xf32, #encoding_bl_output>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="aarch64-xyz-xyz", cpu_features="+neon",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = linalg.generic
+         {indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+          iterator_types = ["parallel", "parallel", "parallel",
+                            "reduction", "reduction", "reduction"]}
+         ins(%input, %filter
+           : tensor<16x16x4xf32, #encoding_bl_input>,
+             tensor<3x3x4x8xf32, #encoding_bl_filter>)
+         outs(%output : tensor<14x14x8xf32, #encoding_bl_output>) {
+  ^bb0(%in: f32, %f: f32, %acc: f32):
+    %m = arith.mulf %in, %f : f32
+    %a = arith.addf %acc, %m : f32
+    linalg.yield %a : f32
+  } -> tensor<14x14x8xf32, #encoding_bl_output>
+  return %0 : tensor<14x14x8xf32, #encoding_bl_output>
+}
+// CHECK-LABEL: func.func @conv2d_batchless_materialize
+// CHECK-SAME:    %[[INPUT:.+]]: tensor<1x16x16x8xf32>
+// CHECK-SAME:    %[[FILTER:.+]]: tensor<1x1x3x3x8x8xf32>
+// CHECK-SAME:    %[[OUTPUT:.+]]: tensor<1x14x14x8xf32>
+// CHECK:         %[[EXP_IN:.+]] = tensor.expand_shape %[[INPUT]] {{\[}}[0, 1], [2], [3], [4]{{\]}}
+// CHECK-SAME:      : tensor<1x16x16x8xf32> into tensor<1x1x16x16x8xf32>
+// CHECK:         %[[EXP_OUT:.+]] = tensor.expand_shape %[[OUTPUT]] {{\[}}[0, 1], [2], [3], [4]{{\]}}
+// CHECK-SAME:      : tensor<1x14x14x8xf32> into tensor<1x1x14x14x8xf32>
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      indexing_maps = [#[[$M_IN]], #[[$M_FLT]], #[[$M_OUT]]]
+// CHECK-SAME:      iterator_types = ["parallel", "parallel", "parallel", "parallel",
+// CHECK-SAME:                        "reduction", "reduction", "reduction",
+// CHECK-SAME:                        "parallel", "reduction"]
+// CHECK-SAME:      ins(%[[EXP_IN]], %[[FILTER]]
+// CHECK-SAME:      outs(%[[EXP_OUT]]
+// CHECK:           %[[MUL:.+]] = arith.mulf
+// CHECK:           %[[ADD:.+]] = arith.addf %[[MUL]]
+// CHECK:           linalg.yield %[[ADD]]
+// CHECK:         %[[COLLAPSE:.+]] = tensor.collapse_shape %[[RESULT]] {{\[}}[0, 1], [2], [3], [4]{{\]}}
+// CHECK-SAME:      : tensor<1x1x14x14x8xf32> into tensor<1x14x14x8xf32>
+// CHECK:         return %[[COLLAPSE]]
diff --git a/compiler/src/iree/compiler/Codegen/Common/test/materialize_encoding_conv_x86_64.mlir b/compiler/src/iree/compiler/Codegen/Common/test/materialize_encoding_conv_x86_64.mlir
new file mode 100644
index 0000000..78101cc
--- /dev/null
+++ b/compiler/src/iree/compiler/Codegen/Common/test/materialize_encoding_conv_x86_64.mlir
@@ -0,0 +1,806 @@
+// RUN: iree-opt --pass-pipeline="builtin.module(func.func(iree-codegen-materialize-device-encoding))" --split-input-file %s | FileCheck %s
+
+// Map invariant materialization (all input formats must produce the same canonical generics):
+//   input:   (d0..d8) -> (d0, d4, d2 + d5, d3 + d6, d8)
+//   filter:  (d0..d8) -> (d1, d4, d5, d6, d8, d7)
+//   output:  (d0..d8) -> (d0, d1, d2, d3, d7)
+
+// conv_2d_nhwc_hwcf materialization.
+
+// Pack input from [N, H, W, C] -> [N, IC/c0, H, W, c0].
+
+#nhwc_hwcf_map_in  = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (n, oh + fh, ow + fw, ic)>
+#nhwc_hwcf_map_f   = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (fh, fw, ic, oc)>
+#nhwc_hwcf_map_out = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (n, oh, ow, oc)>
+
+// CHECK: #[[$M_IN:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d4, d2 + d5, d3 + d6, d8)>
+// CHECK: #[[$M_FLT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d1, d4, d5, d6, d8, d7)>
+// CHECK: #[[$M_OUT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d1, d2, d3, d7)>
+
+#encoding_nhwc_hwcf_input = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_input_pack(%arg0: tensor<1x16x16x4xf32>)
+    -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+  return %0 : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+}
+// CHECK-LABEL: func.func @conv_input_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x16x16x4xf32> -> tensor<1x1x16x16x16xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Pack filter from [KH, KW, C, F] -> [OC/k0, IC/c0, KH, KW, c0, k0].
+
+#encoding_nhwc_hwcf_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_filter_pack(%arg0: tensor<3x3x4x8xf32>)
+    -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+  return %0 : tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+}
+// CHECK-LABEL: func.func @conv_filter_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [3, 2, 0, 1]
+// CHECK-SAME:      inner_dims_pos = [2, 3]
+// CHECK-SAME:      inner_tiles = [16, 16]
+// CHECK-SAME:      : tensor<3x3x4x8xf32> -> tensor<1x1x3x3x16x16xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Unpack output from [N, OC/k0, OH, OW, k0] -> [N, OH, OW, OC].
+
+#encoding_nhwc_hwcf_output = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_output_unset(%arg0: tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+    -> tensor<1x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.unset_encoding %arg0
+       : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output> -> tensor<1x14x14x8xf32>
+  return %0 : tensor<1x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv_output_unset
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x1x14x14x16xf32>
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x1x14x14x16xf32> -> tensor<1x14x14x8xf32>
+// CHECK:         return %[[UNPACK]]
+
+
+// Conv materialization.
+
+func.func @conv2d_nhwc_hwcf_materialize(
+    %input  : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+    %filter : tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>,
+    %output : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+    -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = linalg.conv_2d_nhwc_hwcf
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%input, %filter
+           : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+             tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>)
+         outs(%output : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+         -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  return %0 : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+}
+// CHECK-LABEL: func.func @conv2d_nhwc_hwcf_materialize
+// CHECK-SAME:    %[[INPUT:.+]]: tensor<1x1x16x16x16xf32>
+// CHECK-SAME:    %[[FILTER:.+]]: tensor<1x1x3x3x16x16xf32>
+// CHECK-SAME:    %[[OUTPUT:.+]]: tensor<1x1x14x14x16xf32>
+//
+// 9D data-tiled conv generic with three operands (input, filter, output):
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      indexing_maps = [#[[$M_IN]], #[[$M_FLT]], #[[$M_OUT]]]
+// CHECK-SAME:      iterator_types = ["parallel", "parallel", "parallel", "parallel",
+// CHECK-SAME:                        "reduction", "reduction", "reduction",
+// CHECK-SAME:                        "parallel", "reduction"]
+// CHECK-SAME:      ins(%[[INPUT]], %[[FILTER]]
+// CHECK-SAME:      outs(%[[OUTPUT]]
+// CHECK:           %[[MUL:.+]] = arith.mulf
+// CHECK:           %[[ADD:.+]] = arith.addf %[[MUL]]
+// CHECK:           linalg.yield %[[ADD]]
+// CHECK:         return %[[RESULT]]
+
+
+// Full conv materialization.
+
+func.func @conv2d_nhwc_hwcf_materialize_full(
+    %input  : tensor<1x16x16x4xf32>,
+    %filter : tensor<3x3x4x8xf32>,
+    %output : tensor<1x14x14x8xf32>)
+    -> tensor<1x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %enc_in  = iree_encoding.set_encoding %input
+      : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+  %enc_f   = iree_encoding.set_encoding %filter
+      : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+  %enc_out = iree_encoding.set_encoding %output
+      : tensor<1x14x14x8xf32> -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %0 = linalg.conv_2d_nhwc_hwcf
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%enc_in, %enc_f
+           : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+             tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>)
+         outs(%enc_out : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>)
+         -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output> -> tensor<1x14x14x8xf32>
+  return %1 : tensor<1x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv2d_nhwc_hwcf_materialize_full
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK-SAME:    %[[F:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x14x14x8xf32>
+//
+// CHECK:         %[[PACK_IN:.+]] = linalg.pack %[[IN]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x16x16x4xf32> -> tensor<1x1x16x16x16xf32>
+//
+// CHECK:         %[[PACK_F:.+]] = linalg.pack %[[F]]
+// CHECK-SAME:      outer_dims_perm = [3, 2, 0, 1]
+// CHECK-SAME:      inner_dims_pos = [2, 3]
+// CHECK-SAME:      inner_tiles = [16, 16]
+// CHECK-SAME:      : tensor<3x3x4x8xf32> -> tensor<1x1x3x3x16x16xf32>
+//
+// CHECK:         %[[PACK_OUT:.+]] = linalg.pack %[[OUT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x14x14x8xf32> -> tensor<1x1x14x14x16xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      ins(%[[PACK_IN]], %[[PACK_F]]
+// CHECK-SAME:      outs(%[[PACK_OUT]]
+//
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[RESULT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x1x14x14x16xf32> -> tensor<1x14x14x8xf32>
+
+
+// NHWC-HWCF conv expressed as a linalg.generic carrying conv encodings.
+// It must lower to the same pack / 9D generic / unpack as the named conv2d_nhwc_hwcf_materialize_full above.
+
+func.func @conv2d_nhwc_hwcf_materialize_generic(
+    %input  : tensor<1x16x16x4xf32>,
+    %filter : tensor<3x3x4x8xf32>,
+    %output : tensor<1x14x14x8xf32>)
+    -> tensor<1x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %enc_in  = iree_encoding.set_encoding %input
+      : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>
+  %enc_f   = iree_encoding.set_encoding %filter
+      : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>
+  %enc_out = iree_encoding.set_encoding %output
+      : tensor<1x14x14x8xf32> -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %0 = linalg.generic
+         {indexing_maps = [#nhwc_hwcf_map_in, #nhwc_hwcf_map_f, #nhwc_hwcf_map_out],
+          iterator_types = ["parallel", "parallel", "parallel", "parallel",
+                            "reduction", "reduction", "reduction"]}
+         ins(%enc_in, %enc_f
+           : tensor<1x16x16x4xf32, #encoding_nhwc_hwcf_input>,
+             tensor<3x3x4x8xf32, #encoding_nhwc_hwcf_filter>)
+         outs(%enc_out : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>) {
+  ^bb0(%in: f32, %f: f32, %acc: f32):
+    %m = arith.mulf %in, %f : f32
+    %a = arith.addf %acc, %m : f32
+    linalg.yield %a : f32
+  } -> tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x14x14x8xf32, #encoding_nhwc_hwcf_output> -> tensor<1x14x14x8xf32>
+  return %1 : tensor<1x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv2d_nhwc_hwcf_materialize_generic
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK-SAME:    %[[F:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x14x14x8xf32>
+//
+// CHECK:         %[[PACK_IN:.+]] = linalg.pack %[[IN]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x16x16x4xf32> -> tensor<1x1x16x16x16xf32>
+//
+// CHECK:         %[[PACK_F:.+]] = linalg.pack %[[F]]
+// CHECK-SAME:      outer_dims_perm = [3, 2, 0, 1]
+// CHECK-SAME:      inner_dims_pos = [2, 3]
+// CHECK-SAME:      inner_tiles = [16, 16]
+// CHECK-SAME:      : tensor<3x3x4x8xf32> -> tensor<1x1x3x3x16x16xf32>
+//
+// CHECK:         %[[PACK_OUT:.+]] = linalg.pack %[[OUT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x14x14x8xf32> -> tensor<1x1x14x14x16xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      ins(%[[PACK_IN]], %[[PACK_F]]
+// CHECK-SAME:      outs(%[[PACK_OUT]]
+//
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[RESULT]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x1x14x14x16xf32> -> tensor<1x14x14x8xf32>
+
+
+// -----
+
+// conv_2d_nchw_fchw materialization.
+
+// Pack input from [N, C, H, W] -> [N, IC/c0, H, W, c0].
+
+#nchw_fchw_map_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d4, d2 + d5, d3 + d6)>
+#nchw_fchw_map_f   = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d1, d4, d5, d6)>
+#nchw_fchw_map_out = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>
+
+// CHECK: #[[$M_IN:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d4, d2 + d5, d3 + d6, d8)>
+// CHECK: #[[$M_FLT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d1, d4, d5, d6, d8, d7)>
+// CHECK: #[[$M_OUT:.+]] = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d1, d2, d3, d7)>
+
+#encoding_nchw_fchw_input = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nchw_fchw_map_in, #nchw_fchw_map_f, #nchw_fchw_map_out],
+  iteration_sizes = [1, 8, 14, 14, 4, 3, 3]>
+
+func.func @conv_nchw_input_pack(%arg0: tensor<1x4x16x16xf32>)
+    -> tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<1x4x16x16xf32> -> tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>
+  return %0 : tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>
+}
+// CHECK-LABEL: func.func @conv_nchw_input_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x4x16x16xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x4x16x16xf32> -> tensor<1x1x16x16x16xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Pack filter from [F, C, KH, KW] -> [OC/k0, IC/c0, KH, KW, c0, k0].
+
+#encoding_nchw_fchw_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nchw_fchw_map_in, #nchw_fchw_map_f, #nchw_fchw_map_out],
+  iteration_sizes = [1, 8, 14, 14, 4, 3, 3]>
+
+func.func @conv_fchw_filter_pack(%arg0: tensor<8x4x3x3xf32>)
+    -> tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<8x4x3x3xf32> -> tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>
+  return %0 : tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>
+}
+// CHECK-LABEL: func.func @conv_fchw_filter_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<8x4x3x3xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1, 0]
+// CHECK-SAME:      inner_tiles = [16, 16]
+// CHECK-SAME:      : tensor<8x4x3x3xf32> -> tensor<1x1x3x3x16x16xf32>
+// CHECK:         return %[[PACK]]
+
+
+// Unpack output from [N, OC/k0, OH, OW, k0] -> [N, OC, OH, OW].
+
+#encoding_nchw_fchw_output = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nchw_fchw_map_in, #nchw_fchw_map_f, #nchw_fchw_map_out],
+  iteration_sizes = [1, 8, 14, 14, 4, 3, 3]>
+
+func.func @conv_nchw_output_unset(%arg0: tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>)
+    -> tensor<1x8x14x14xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.unset_encoding %arg0
+       : tensor<1x8x14x14xf32, #encoding_nchw_fchw_output> -> tensor<1x8x14x14xf32>
+  return %0 : tensor<1x8x14x14xf32>
+}
+// CHECK-LABEL: func.func @conv_nchw_output_unset
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<1x1x14x14x16xf32>
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x1x14x14x16xf32> -> tensor<1x8x14x14xf32>
+// CHECK:         return %[[UNPACK]]
+
+
+// Conv materialization.
+
+func.func @conv2d_nchw_fchw_materialize(
+    %input  : tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>,
+    %filter : tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>,
+    %output : tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>)
+    -> tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = linalg.conv_2d_nchw_fchw
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%input, %filter
+           : tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>,
+             tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>)
+         outs(%output : tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>)
+         -> tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>
+  return %0 : tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>
+}
+// CHECK-LABEL: func.func @conv2d_nchw_fchw_materialize
+// CHECK-SAME:    %[[INPUT:.+]]: tensor<1x1x16x16x16xf32>
+// CHECK-SAME:    %[[FILTER:.+]]: tensor<1x1x3x3x16x16xf32>
+// CHECK-SAME:    %[[OUTPUT:.+]]: tensor<1x1x14x14x16xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      indexing_maps = [#[[$M_IN]], #[[$M_FLT]], #[[$M_OUT]]]
+// CHECK-SAME:      iterator_types = ["parallel", "parallel", "parallel", "parallel",
+// CHECK-SAME:                        "reduction", "reduction", "reduction",
+// CHECK-SAME:                        "parallel", "reduction"]
+// CHECK-SAME:      ins(%[[INPUT]], %[[FILTER]]
+// CHECK-SAME:      outs(%[[OUTPUT]]
+// CHECK:           %[[MUL:.+]] = arith.mulf
+// CHECK:           %[[ADD:.+]] = arith.addf %[[MUL]]
+// CHECK:           linalg.yield %[[ADD]]
+// CHECK:         return %[[RESULT]]
+
+
+// Full conv materialization.
+
+func.func @conv2d_nchw_fchw_materialize_full(
+    %input  : tensor<1x4x16x16xf32>,
+    %filter : tensor<8x4x3x3xf32>,
+    %output : tensor<1x8x14x14xf32>)
+    -> tensor<1x8x14x14xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %enc_in  = iree_encoding.set_encoding %input
+      : tensor<1x4x16x16xf32> -> tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>
+  %enc_f   = iree_encoding.set_encoding %filter
+      : tensor<8x4x3x3xf32> -> tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>
+  %enc_out = iree_encoding.set_encoding %output
+      : tensor<1x8x14x14xf32> -> tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>
+  %0 = linalg.conv_2d_nchw_fchw
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%enc_in, %enc_f
+           : tensor<1x4x16x16xf32, #encoding_nchw_fchw_input>,
+             tensor<8x4x3x3xf32, #encoding_nchw_fchw_filter>)
+         outs(%enc_out : tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>)
+         -> tensor<1x8x14x14xf32, #encoding_nchw_fchw_output>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x8x14x14xf32, #encoding_nchw_fchw_output> -> tensor<1x8x14x14xf32>
+  return %1 : tensor<1x8x14x14xf32>
+}
+// CHECK-LABEL: func.func @conv2d_nchw_fchw_materialize_full
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x4x16x16xf32>
+// CHECK-SAME:    %[[F:[a-zA-Z0-9]+]]: tensor<8x4x3x3xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x8x14x14xf32>
+//
+// CHECK:         %[[PACK_IN:.+]] = linalg.pack %[[IN]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x4x16x16xf32> -> tensor<1x1x16x16x16xf32>
+//
+// CHECK:         %[[PACK_F:.+]] = linalg.pack %[[F]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1, 0]
+// CHECK-SAME:      inner_tiles = [16, 16]
+// CHECK-SAME:      : tensor<8x4x3x3xf32> -> tensor<1x1x3x3x16x16xf32>
+//
+// CHECK:         %[[PACK_OUT:.+]] = linalg.pack %[[OUT]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x8x14x14xf32> -> tensor<1x1x14x14x16xf32>
+//
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      ins(%[[PACK_IN]], %[[PACK_F]]
+// CHECK-SAME:      outs(%[[PACK_OUT]]
+//
+// CHECK:         %[[UNPACK:.+]] = linalg.unpack %[[RESULT]]
+// CHECK-SAME:      outer_dims_perm = [0, 1, 2, 3]
+// CHECK-SAME:      inner_dims_pos = [1]
+// CHECK-SAME:      inner_tiles = [16]
+// CHECK-SAME:      : tensor<1x1x14x14x16xf32> -> tensor<1x8x14x14xf32>
+
+// -----
+
+// conv_2d_nhwc_fhwc materialization.
+
+// Pack filter from [F, KH, KW, C] -> [OC/k0, IC/c0, KH, KW, c0, k0].
+
+#nhwc_fhwc_map_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1 + d4, d2 + d5, d6)>
+#nhwc_fhwc_map_f   = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d3, d4, d5, d6)>
+#nhwc_fhwc_map_out = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>
+
+#encoding_nhwc_fhwc_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#nhwc_fhwc_map_in, #nhwc_fhwc_map_f, #nhwc_fhwc_map_out],
+  iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+func.func @conv_fhwc_filter_pack(%arg0: tensor<8x3x3x4xf32>)
+    -> tensor<8x3x3x4xf32, #encoding_nhwc_fhwc_filter>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = iree_encoding.set_encoding %arg0
+       : tensor<8x3x3x4xf32> -> tensor<8x3x3x4xf32, #encoding_nhwc_fhwc_filter>
+  return %0 : tensor<8x3x3x4xf32, #encoding_nhwc_fhwc_filter>
+}
+// CHECK-LABEL: func.func @conv_fhwc_filter_pack
+// CHECK-SAME:    %[[ARG0:[a-zA-Z0-9]+]]: tensor<8x3x3x4xf32>
+// CHECK:         %[[PACK:.+]] = linalg.pack %[[ARG0]]
+// CHECK-SAME:      outer_dims_perm = [0, 3, 1, 2]
+// CHECK-SAME:      inner_dims_pos = [3, 0]
+// CHECK-SAME:      inner_tiles = [16, 16]
+// CHECK-SAME:      : tensor<8x3x3x4xf32> -> tensor<1x1x3x3x16x16xf32>
+// CHECK:         return %[[PACK]]
+
+// -----
+
+// Unit batch-dim folded conv: N=1 stripped upstream, so operands pack to rank-4
+// and the lowering restores the batch via expand/collapse around the 9-D generic.
+
+#bl_map_in  = affine_map<(oh, ow, oc, fh, fw, ic) -> (oh + fh, ow + fw, ic)>
+#bl_map_f   = affine_map<(oh, ow, oc, fh, fw, ic) -> (fh, fw, ic, oc)>
+#bl_map_out = affine_map<(oh, ow, oc, fh, fw, ic) -> (oh, ow, oc)>
+
+#encoding_bl_input = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+  iteration_sizes = [14, 14, 8, 3, 3, 4]>
+#encoding_bl_filter = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+  iteration_sizes = [14, 14, 8, 3, 3, 4]>
+#encoding_bl_output = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+  iteration_sizes = [14, 14, 8, 3, 3, 4]>
+
+func.func @conv2d_batchless_materialize(
+    %input  : tensor<16x16x4xf32, #encoding_bl_input>,
+    %filter : tensor<3x3x4x8xf32, #encoding_bl_filter>,
+    %output : tensor<14x14x8xf32, #encoding_bl_output>)
+    -> tensor<14x14x8xf32, #encoding_bl_output>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %0 = linalg.generic
+         {indexing_maps = [#bl_map_in, #bl_map_f, #bl_map_out],
+          iterator_types = ["parallel", "parallel", "parallel",
+                            "reduction", "reduction", "reduction"]}
+         ins(%input, %filter
+           : tensor<16x16x4xf32, #encoding_bl_input>,
+             tensor<3x3x4x8xf32, #encoding_bl_filter>)
+         outs(%output : tensor<14x14x8xf32, #encoding_bl_output>) {
+  ^bb0(%in: f32, %f: f32, %acc: f32):
+    %m = arith.mulf %in, %f : f32
+    %a = arith.addf %acc, %m : f32
+    linalg.yield %a : f32
+  } -> tensor<14x14x8xf32, #encoding_bl_output>
+  return %0 : tensor<14x14x8xf32, #encoding_bl_output>
+}
+// CHECK-LABEL: func.func @conv2d_batchless_materialize
+// CHECK-SAME:    %[[INPUT:.+]]: tensor<1x16x16x16xf32>
+// CHECK-SAME:    %[[FILTER:.+]]: tensor<1x1x3x3x16x16xf32>
+// CHECK-SAME:    %[[OUTPUT:.+]]: tensor<1x14x14x16xf32>
+// CHECK:         %[[EXP_IN:.+]] = tensor.expand_shape %[[INPUT]] {{\[}}[0, 1], [2], [3], [4]{{\]}}
+// CHECK-SAME:      : tensor<1x16x16x16xf32> into tensor<1x1x16x16x16xf32>
+// CHECK:         %[[EXP_OUT:.+]] = tensor.expand_shape %[[OUTPUT]] {{\[}}[0, 1], [2], [3], [4]{{\]}}
+// CHECK-SAME:      : tensor<1x14x14x16xf32> into tensor<1x1x14x14x16xf32>
+// CHECK:         %[[RESULT:.+]] = linalg.generic
+// CHECK-SAME:      indexing_maps = [#[[$M_IN]], #[[$M_FLT]], #[[$M_OUT]]]
+// CHECK-SAME:      iterator_types = ["parallel", "parallel", "parallel", "parallel",
+// CHECK-SAME:                        "reduction", "reduction", "reduction",
+// CHECK-SAME:                        "parallel", "reduction"]
+// CHECK-SAME:      ins(%[[EXP_IN]], %[[FILTER]]
+// CHECK-SAME:      outs(%[[EXP_OUT]]
+// CHECK:           %[[MUL:.+]] = arith.mulf
+// CHECK:           %[[ADD:.+]] = arith.addf %[[MUL]]
+// CHECK:           linalg.yield %[[ADD]]
+// CHECK:         %[[COLLAPSE:.+]] = tensor.collapse_shape %[[RESULT]] {{\[}}[0, 1], [2], [3], [4]{{\]}}
+// CHECK-SAME:      : tensor<1x1x14x14x16xf32> into tensor<1x14x14x16xf32>
+// CHECK:         return %[[COLLAPSE]]
+
+// -----
+
+// Unsupported convs: the resolver only data-tiles plain 2D convs, so grouped,
+// non-2D (1D/3D), and dilated convs resolve to identity layout.
+
+// 1D conv (nwc_wcf)
+
+#map_1d_in  = affine_map<(d0, d1, d2, d3, d4) -> (d0, d1 + d3, d4)>
+#map_1d_flt = affine_map<(d0, d1, d2, d3, d4) -> (d3, d4, d2)>
+#map_1d_out = affine_map<(d0, d1, d2, d3, d4) -> (d0, d1, d2)>
+#encoding_1d_in = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_1d_in, #map_1d_flt, #map_1d_out],
+  iteration_sizes = [1, 14, 8, 3, 4]>
+#encoding_1d_flt = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_1d_in, #map_1d_flt, #map_1d_out],
+  iteration_sizes = [1, 14, 8, 3, 4]>
+#encoding_1d_out = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_1d_in, #map_1d_flt, #map_1d_out],
+  iteration_sizes = [1, 14, 8, 3, 4]>
+func.func @conv1d_unsupported_identity(
+    %in  : tensor<1x16x4xf32>,
+    %flt : tensor<3x4x8xf32>,
+    %out : tensor<1x14x8xf32>)
+    -> tensor<1x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %e_in  = iree_encoding.set_encoding %in
+      : tensor<1x16x4xf32> -> tensor<1x16x4xf32, #encoding_1d_in>
+  %e_flt = iree_encoding.set_encoding %flt
+      : tensor<3x4x8xf32> -> tensor<3x4x8xf32, #encoding_1d_flt>
+  %e_out = iree_encoding.set_encoding %out
+      : tensor<1x14x8xf32> -> tensor<1x14x8xf32, #encoding_1d_out>
+  %0 = linalg.conv_1d_nwc_wcf
+         {dilations = dense<1> : tensor<1xi64>, strides = dense<1> : tensor<1xi64>}
+         ins(%e_in, %e_flt
+           : tensor<1x16x4xf32, #encoding_1d_in>, tensor<3x4x8xf32, #encoding_1d_flt>)
+         outs(%e_out : tensor<1x14x8xf32, #encoding_1d_out>)
+         -> tensor<1x14x8xf32, #encoding_1d_out>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x14x8xf32, #encoding_1d_out> -> tensor<1x14x8xf32>
+  return %1 : tensor<1x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv1d_unsupported_identity
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x4xf32>
+// CHECK-SAME:    %[[FLT:[a-zA-Z0-9]+]]: tensor<3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x14x8xf32>
+// CHECK-NOT:     linalg.pack
+// CHECK-NOT:     iree_encoding
+// CHECK:         %[[CONV:.+]] = linalg.conv_1d_nwc_wcf
+// CHECK-SAME:      ins(%[[IN]], %[[FLT]] : tensor<1x16x4xf32>, tensor<3x4x8xf32>)
+// CHECK-SAME:      outs(%[[OUT]] : tensor<1x14x8xf32>)
+// CHECK-NOT:     linalg.unpack
+// CHECK:         return %[[CONV]]
+
+// -----
+
+// 2D grouped conv (ngchw_fgchw)
+
+#map_g_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7) -> (d0, d1, d5, d3 + d6, d4 + d7)>
+#map_g_flt = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7) -> (d2, d1, d5, d6, d7)>
+#map_g_out = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7) -> (d0, d1, d2, d3, d4)>
+#encoding_g_in = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_g_in, #map_g_flt, #map_g_out],
+  iteration_sizes = [1, 2, 8, 14, 14, 4, 3, 3]>
+#encoding_g_flt = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_g_in, #map_g_flt, #map_g_out],
+  iteration_sizes = [1, 2, 8, 14, 14, 4, 3, 3]>
+#encoding_g_out = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_g_in, #map_g_flt, #map_g_out],
+  iteration_sizes = [1, 2, 8, 14, 14, 4, 3, 3]>
+func.func @conv2d_grouped_unsupported_identity(
+    %in  : tensor<1x2x4x16x16xf32>,
+    %flt : tensor<8x2x4x3x3xf32>,
+    %out : tensor<1x2x8x14x14xf32>)
+    -> tensor<1x2x8x14x14xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %e_in  = iree_encoding.set_encoding %in
+      : tensor<1x2x4x16x16xf32> -> tensor<1x2x4x16x16xf32, #encoding_g_in>
+  %e_flt = iree_encoding.set_encoding %flt
+      : tensor<8x2x4x3x3xf32> -> tensor<8x2x4x3x3xf32, #encoding_g_flt>
+  %e_out = iree_encoding.set_encoding %out
+      : tensor<1x2x8x14x14xf32> -> tensor<1x2x8x14x14xf32, #encoding_g_out>
+  %0 = linalg.conv_2d_ngchw_fgchw
+         {dilations = dense<1> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%e_in, %e_flt
+           : tensor<1x2x4x16x16xf32, #encoding_g_in>, tensor<8x2x4x3x3xf32, #encoding_g_flt>)
+         outs(%e_out : tensor<1x2x8x14x14xf32, #encoding_g_out>)
+         -> tensor<1x2x8x14x14xf32, #encoding_g_out>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x2x8x14x14xf32, #encoding_g_out> -> tensor<1x2x8x14x14xf32>
+  return %1 : tensor<1x2x8x14x14xf32>
+}
+// CHECK-LABEL: func.func @conv2d_grouped_unsupported_identity
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x2x4x16x16xf32>
+// CHECK-SAME:    %[[FLT:[a-zA-Z0-9]+]]: tensor<8x2x4x3x3xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x2x8x14x14xf32>
+// CHECK-NOT:     linalg.pack
+// CHECK-NOT:     iree_encoding
+// CHECK:         %[[CONV:.+]] = linalg.conv_2d_ngchw_fgchw
+// CHECK-SAME:      ins(%[[IN]], %[[FLT]] : tensor<1x2x4x16x16xf32>, tensor<8x2x4x3x3xf32>)
+// CHECK-SAME:      outs(%[[OUT]] : tensor<1x2x8x14x14xf32>)
+// CHECK-NOT:     linalg.unpack
+// CHECK:         return %[[CONV]]
+
+// -----
+
+// 3D conv (ndhwc_dhwcf)
+
+#map_3d_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d1 + d5, d2 + d6, d3 + d7, d8)>
+#map_3d_flt = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d5, d6, d7, d8, d4)>
+#map_3d_out = affine_map<(d0, d1, d2, d3, d4, d5, d6, d7, d8) -> (d0, d1, d2, d3, d4)>
+#encoding_3d_in = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_3d_in, #map_3d_flt, #map_3d_out],
+  iteration_sizes = [1, 14, 14, 14, 8, 3, 3, 3, 4]>
+#encoding_3d_flt = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_3d_in, #map_3d_flt, #map_3d_out],
+  iteration_sizes = [1, 14, 14, 14, 8, 3, 3, 3, 4]>
+#encoding_3d_out = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_3d_in, #map_3d_flt, #map_3d_out],
+  iteration_sizes = [1, 14, 14, 14, 8, 3, 3, 3, 4]>
+func.func @conv3d_unsupported_identity(
+    %in  : tensor<1x16x16x16x4xf32>,
+    %flt : tensor<3x3x3x4x8xf32>,
+    %out : tensor<1x14x14x14x8xf32>)
+    -> tensor<1x14x14x14x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %e_in  = iree_encoding.set_encoding %in
+      : tensor<1x16x16x16x4xf32> -> tensor<1x16x16x16x4xf32, #encoding_3d_in>
+  %e_flt = iree_encoding.set_encoding %flt
+      : tensor<3x3x3x4x8xf32> -> tensor<3x3x3x4x8xf32, #encoding_3d_flt>
+  %e_out = iree_encoding.set_encoding %out
+      : tensor<1x14x14x14x8xf32> -> tensor<1x14x14x14x8xf32, #encoding_3d_out>
+  %0 = linalg.conv_3d_ndhwc_dhwcf
+         {dilations = dense<1> : tensor<3xi64>, strides = dense<1> : tensor<3xi64>}
+         ins(%e_in, %e_flt
+           : tensor<1x16x16x16x4xf32, #encoding_3d_in>, tensor<3x3x3x4x8xf32, #encoding_3d_flt>)
+         outs(%e_out : tensor<1x14x14x14x8xf32, #encoding_3d_out>)
+         -> tensor<1x14x14x14x8xf32, #encoding_3d_out>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x14x14x14x8xf32, #encoding_3d_out> -> tensor<1x14x14x14x8xf32>
+  return %1 : tensor<1x14x14x14x8xf32>
+}
+// CHECK-LABEL: func.func @conv3d_unsupported_identity
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x16x16x4xf32>
+// CHECK-SAME:    %[[FLT:[a-zA-Z0-9]+]]: tensor<3x3x3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x14x14x14x8xf32>
+// CHECK-NOT:     linalg.pack
+// CHECK-NOT:     iree_encoding
+// CHECK:         %[[CONV:.+]] = linalg.conv_3d_ndhwc_dhwcf
+// CHECK-SAME:      ins(%[[IN]], %[[FLT]] : tensor<1x16x16x16x4xf32>, tensor<3x3x3x4x8xf32>)
+// CHECK-SAME:      outs(%[[OUT]] : tensor<1x14x14x14x8xf32>)
+// CHECK-NOT:     linalg.unpack
+// CHECK:         return %[[CONV]]
+
+// -----
+
+// Dilated 2D conv
+
+#map_d_in  = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1 + d4 * 2, d2 + d5 * 2, d6)>
+#map_d_flt = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d4, d5, d6, d3)>
+#map_d_out = affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>
+#encoding_d_in = #iree_encoding.encoding<operand_index = 0, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_d_in, #map_d_flt, #map_d_out],
+  iteration_sizes = [1, 12, 12, 8, 3, 3, 4]>
+#encoding_d_flt = #iree_encoding.encoding<operand_index = 1, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_d_in, #map_d_flt, #map_d_out],
+  iteration_sizes = [1, 12, 12, 8, 3, 3, 4]>
+#encoding_d_out = #iree_encoding.encoding<operand_index = 2, op_type = conv,
+  element_types = [f32, f32, f32],
+  user_indexing_maps = [#map_d_in, #map_d_flt, #map_d_out],
+  iteration_sizes = [1, 12, 12, 8, 3, 3, 4]>
+func.func @conv2d_dilated_unsupported_identity(
+    %in  : tensor<1x16x16x4xf32>,
+    %flt : tensor<3x3x4x8xf32>,
+    %out : tensor<1x12x12x8xf32>)
+    -> tensor<1x12x12x8xf32>
+    attributes {
+  hal.executable.target = #hal.executable.target<"llvm-cpu", "xyz",
+      {target_triple="x86_64-xyz-xyz", cpu_features="+avx512f",
+       iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>}>
+} {
+  %e_in  = iree_encoding.set_encoding %in
+      : tensor<1x16x16x4xf32> -> tensor<1x16x16x4xf32, #encoding_d_in>
+  %e_flt = iree_encoding.set_encoding %flt
+      : tensor<3x3x4x8xf32> -> tensor<3x3x4x8xf32, #encoding_d_flt>
+  %e_out = iree_encoding.set_encoding %out
+      : tensor<1x12x12x8xf32> -> tensor<1x12x12x8xf32, #encoding_d_out>
+  %0 = linalg.conv_2d_nhwc_hwcf
+         {dilations = dense<2> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+         ins(%e_in, %e_flt
+           : tensor<1x16x16x4xf32, #encoding_d_in>, tensor<3x3x4x8xf32, #encoding_d_flt>)
+         outs(%e_out : tensor<1x12x12x8xf32, #encoding_d_out>)
+         -> tensor<1x12x12x8xf32, #encoding_d_out>
+  %1 = iree_encoding.unset_encoding %0
+      : tensor<1x12x12x8xf32, #encoding_d_out> -> tensor<1x12x12x8xf32>
+  return %1 : tensor<1x12x12x8xf32>
+}
+// CHECK-LABEL: func.func @conv2d_dilated_unsupported_identity
+// CHECK-SAME:    %[[IN:[a-zA-Z0-9]+]]: tensor<1x16x16x4xf32>
+// CHECK-SAME:    %[[FLT:[a-zA-Z0-9]+]]: tensor<3x3x4x8xf32>
+// CHECK-SAME:    %[[OUT:[a-zA-Z0-9]+]]: tensor<1x12x12x8xf32>
+// CHECK-NOT:     linalg.pack
+// CHECK-NOT:     iree_encoding
+// CHECK:         %[[CONV:.+]] = linalg.conv_2d_nhwc_hwcf
+// CHECK-SAME:      {dilations = dense<2> : tensor<2xi64>, strides = dense<1> : tensor<2xi64>}
+// CHECK-SAME:      ins(%[[IN]], %[[FLT]] : tensor<1x16x16x4xf32>, tensor<3x3x4x8xf32>)
+// CHECK-SAME:      outs(%[[OUT]] : tensor<1x12x12x8xf32>)
+// CHECK-NOT:     linalg.unpack
+// CHECK:         return %[[CONV]]
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/BUILD.bazel b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/BUILD.bazel
index 70299ac..724165f 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/BUILD.bazel
+++ b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/BUILD.bazel
@@ -30,6 +30,7 @@
         "@llvm-project//mlir:DialectUtils",
         "@llvm-project//mlir:IR",
         "@llvm-project//mlir:LinalgDialect",
+        "@llvm-project//mlir:LinalgInterfaces",
         "@llvm-project//mlir:Support",
         "@llvm-project//mlir:TensorDialect",
     ],
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/CMakeLists.txt b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/CMakeLists.txt
index 1870c9f..5f4ce3a 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/CMakeLists.txt
+++ b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/CMakeLists.txt
@@ -22,6 +22,7 @@
     MLIRArithDialect
     MLIRIR
     MLIRLinalgDialect
+    MLIRLinalgInterfacesIncGenLib
     MLIRSupport
     MLIRTensorDialect
     iree::compiler::Codegen::Dialect::Codegen::IR::IREECodegenDialect
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.cpp b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.cpp
index beb9b59..d49aad4 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.cpp
+++ b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.cpp
@@ -10,7 +10,11 @@
 #include "iree/compiler/Dialect/LinalgExt/Utils/MatchUtils.h"
 #include "llvm/Support/Debug.h"
 #include "llvm/Support/InterleavedRange.h"
+#include "mlir/Dialect/Linalg/IR/LinalgInterfaces.h"
 #include "mlir/Dialect/Utils/StaticValueUtils.h"
+#include "mlir/Dialect/Utils/StructuredOpsUtils.h"
+#include "mlir/IR/AffineExpr.h"
+#include "mlir/IR/AffineMap.h"
 #include "mlir/IR/Attributes.h"
 #include "mlir/IR/Builders.h"
 #include "mlir/IR/BuiltinAttributes.h"
@@ -526,4 +530,164 @@
   return encodingInfo;
 }
 
+FailureOr<MaterializeEncodingInfo>
+getEncodingInfoForConv(Encoding::EncodingAttr encoding, TileOCxIC tile) {
+  int64_t operandIdx = encoding.getOperandIndex().getInt();
+  SmallVector<AffineMap> maps = encoding.getRootMaps();
+
+  FailureOr<linalg::ConvolutionDimensions> cDims =
+      linalg::inferConvolutionDims(maps);
+  if (failed(cDims)) {
+    return failure();
+  }
+
+  // Data tiling requires both input and output channel dimensions.
+  if (cDims->inputChannel.empty() || cDims->outputChannel.empty()) {
+    return failure();
+  }
+
+  // Grouped/depthwise convolutions are not supported. A depth dimension
+  // appears as a bare dim in the input map and would be misclassified as
+  // an output image dimension by the position inference below.
+  if (!cDims->depth.empty()) {
+    return failure();
+  }
+
+  // Map classified loop dims to tensor positions for this operand.
+  //
+  // A loop dimension can be mapped to a tensor position only if it appears
+  // as a bare dimension in the operand's indexing map. Dimensions that occur
+  // only inside a compound affine expression have no single tensor position
+  // and cannot be recovered this way.
+  //
+  // For example: linalg.conv_2d_nhwc_hwcf has input indexing map
+  //   (n, oh + fh, ow + fw, ic)
+  // where the loop dimensions are
+  //   (n, oh, ow, oc, fh, fw, ic) = (0, 1, 2, 3, 4, 5, 6).
+  // Only n and ic appear as bare dimensions, at tensor positions 0 and 3.
+  // The outputImage dimensions (oh, ow) appear only in compound expressions, so
+  // they cannot be recovered here.
+  auto collectBarePositions =
+      [&encoding](ArrayRef<unsigned> loopDims) -> SmallVector<int64_t> {
+    SmallVector<int64_t> positions;
+    for (unsigned d : loopDims) {
+      if (auto pos = encoding.mapDimToOperandIndex(d)) {
+        positions.push_back(static_cast<int64_t>(*pos));
+      }
+    }
+    return positions;
+  };
+
+  // For the input operand, recover the outputImage tensor positions.
+  //
+  // From the example above, the known positions are {0, 3}, so the remaining
+  // tensor positions {1, 2} correspond to the outputImage dimensions.
+  auto collectRemainingPositions =
+      [](int64_t rank,
+         ArrayRef<int64_t> knownPositions) -> SmallVector<int64_t> {
+    llvm::SmallDenseSet<int64_t> known(knownPositions.begin(),
+                                       knownPositions.end());
+    SmallVector<int64_t> remaining;
+    for (int64_t i = 0; i < rank; ++i) {
+      if (!known.contains(i)) {
+        remaining.push_back(i);
+      }
+    }
+    return remaining;
+  };
+
+  SmallVector<int64_t> batchPos = collectBarePositions(cDims->batch);
+  SmallVector<int64_t> ocPos = collectBarePositions(cDims->outputChannel);
+  SmallVector<int64_t> flPos = collectBarePositions(cDims->filterLoop);
+  SmallVector<int64_t> icPos = collectBarePositions(cDims->inputChannel);
+
+  MaterializeEncodingInfo info;
+
+  if (operandIdx == Encoding::CONV_IN) {
+    if (icPos.empty()) {
+      return failure();
+    }
+    int64_t rank = maps[operandIdx].getNumResults();
+    SmallVector<int64_t> knownInputPos;
+    knownInputPos.append(batchPos);
+    knownInputPos.append(icPos);
+    SmallVector<int64_t> outputImagePosInInput =
+        collectRemainingPositions(rank, knownInputPos);
+
+    SmallVector<int64_t> canonicalOrder;
+    canonicalOrder.append(batchPos);
+    canonicalOrder.append(icPos);
+    canonicalOrder.append(outputImagePosInInput);
+
+    info.outerDimsPerm = canonicalOrder;
+    info.innerDimsPos = {icPos[0]};
+    info.innerTileSizes = {tile.IC};
+  } else if (operandIdx == Encoding::CONV_FILTER) {
+    if (ocPos.empty() || icPos.empty()) {
+      return failure();
+    }
+    SmallVector<int64_t> canonicalOrder;
+    canonicalOrder.append(ocPos);
+    canonicalOrder.append(icPos);
+    canonicalOrder.append(flPos);
+
+    info.outerDimsPerm = canonicalOrder;
+    info.innerDimsPos = {icPos[0], ocPos[0]};
+    info.innerTileSizes = {tile.IC, tile.OC};
+  } else if (operandIdx == Encoding::CONV_OUT) {
+    if (ocPos.empty()) {
+      return failure();
+    }
+    SmallVector<int64_t> outputImagePosInOutput =
+        collectBarePositions(cDims->outputImage);
+
+    SmallVector<int64_t> canonicalOrder;
+    canonicalOrder.append(batchPos);
+    canonicalOrder.append(ocPos);
+    canonicalOrder.append(outputImagePosInOutput);
+
+    info.outerDimsPerm = canonicalOrder;
+    info.innerDimsPos = {ocPos[0]};
+    info.innerTileSizes = {tile.OC};
+  } else {
+    return failure();
+  }
+
+  return info;
+}
+
+DataTiledConvIterationSpace
+getDataTiledConvIterationSpace(MLIRContext *ctx, ArrayRef<int64_t> strides,
+                               ArrayRef<int64_t> dilations) {
+  assert(strides.size() == 2 && dilations.size() == 2 &&
+         "expected 2D convolution strides/dilations");
+  AffineExpr e0, e1, e2, e3, e4, e5, e6, e7, e8;
+  bindDims(ctx, e0, e1, e2, e3, e4, e5, e6, e7, e8);
+
+  DataTiledConvIterationSpace space;
+  space.indexingMaps = {
+      // input: [N, IC/c0, H, W, c0]
+      AffineMap::get(9, 0,
+                     {e0, e4, e2 * strides[0] + e5 * dilations[0],
+                      e3 * strides[1] + e6 * dilations[1], e8},
+                     ctx),
+      // filter: [OC/k0, IC/c0, FH, FW, c0, k0]
+      AffineMap::get(9, 0, {e1, e4, e5, e6, e8, e7}, ctx),
+      // output: [N, OC/k0, OH, OW, k0]
+      AffineMap::get(9, 0, {e0, e1, e2, e3, e7}, ctx),
+  };
+  space.iteratorTypes = {
+      utils::IteratorType::parallel,  // d0 = n
+      utils::IteratorType::parallel,  // d1 = oc_outer
+      utils::IteratorType::parallel,  // d2 = oh
+      utils::IteratorType::parallel,  // d3 = ow
+      utils::IteratorType::reduction, // d4 = ic_outer
+      utils::IteratorType::reduction, // d5 = fh
+      utils::IteratorType::reduction, // d6 = fw
+      utils::IteratorType::parallel,  // d7 = oc_inner (k0)
+      utils::IteratorType::reduction, // d8 = ic_inner (c0)
+  };
+  return space;
+}
+
 } // namespace mlir::iree_compiler::IREE::Codegen
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.h b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.h
index 76f816a..b9c509d 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.h
+++ b/compiler/src/iree/compiler/Codegen/Dialect/Codegen/Utils/Utils.h
@@ -11,6 +11,8 @@
 #include "iree/compiler/Codegen/Dialect/Codegen/IR/IREECodegenTypes.h"
 #include "iree/compiler/Dialect/Encoding/IR/EncodingOps.h"
 #include "llvm/Support/raw_ostream.h"
+#include "mlir/Dialect/Utils/StructuredOpsUtils.h"
+#include "mlir/IR/AffineMap.h"
 #include "mlir/IR/BuiltinAttributes.h"
 #include "mlir/IR/MLIRContext.h"
 #include "mlir/Support/LLVM.h"
@@ -124,6 +126,45 @@
 FailureOr<MaterializeEncodingInfo>
 getEncodingInfoForMatmul(Encoding::EncodingAttr encoding, TileMxNxK tileMxNxK);
 
+/// Tile sizes for convolution encoding.
+struct TileOCxIC {
+  int64_t OC = 1; // k0: output channel tile
+  int64_t IC = 1; // c0: input channel tile
+};
+
+/// Returns MaterializeEncodingInfo for a convolution operand.
+FailureOr<MaterializeEncodingInfo>
+getEncodingInfoForConv(Encoding::EncodingAttr encoding, TileOCxIC tile);
+
+//===---------------------------------------------------------------------===//
+// Data-tiled convolution utilities.
+//===---------------------------------------------------------------------===//
+
+/// Iteration space of the data-tiled convolution generic. This is the
+/// canonical definition: the materialization builder and any code that
+/// inspects the resulting op both derive from it.
+///
+///   Loop dims: (n, oc_outer, oh, ow, ic_outer, fh, fw, oc_inner, ic_inner)
+///               d0  d1        d2  d3  d4        d5  d6  d7        d8
+///   input:  -> (d0, d4, d2*sH + d5*dH, d3*sW + d6*dW, d8)
+///   filter: -> (d1, d4, d5, d6, d8, d7)
+///   output: -> (d0, d1, d2, d3, d7)
+///
+/// corresponding to packed operand layouts:
+///   input:  [N, IC/c0, H, W, c0]
+///   filter: [OC/k0, IC/c0, FH, FW, c0, k0]
+///   output: [N, OC/k0, OH, OW, k0]
+struct DataTiledConvIterationSpace {
+  SmallVector<AffineMap> indexingMaps;
+  SmallVector<utils::IteratorType> iteratorTypes;
+};
+
+/// Returns the indexing maps and iterator types for a data-tiled 2D
+/// convolution with the given strides and dilations.
+DataTiledConvIterationSpace
+getDataTiledConvIterationSpace(MLIRContext *ctx, ArrayRef<int64_t> strides,
+                               ArrayRef<int64_t> dilations);
+
 } // namespace mlir::iree_compiler::IREE::Codegen
 
 #endif // IREE_COMPILER_CODEGEN_DIALECT_CODEGEN_UTILS_H_
diff --git a/compiler/src/iree/compiler/Codegen/ExternalInterfaces/CPUEncodingExternalModels.cpp b/compiler/src/iree/compiler/Codegen/ExternalInterfaces/CPUEncodingExternalModels.cpp
index e3d2324..7260db7 100644
--- a/compiler/src/iree/compiler/Codegen/ExternalInterfaces/CPUEncodingExternalModels.cpp
+++ b/compiler/src/iree/compiler/Codegen/ExternalInterfaces/CPUEncodingExternalModels.cpp
@@ -64,6 +64,7 @@
 
 using IREE::Codegen::MaterializeEncodingInfo;
 using IREE::Codegen::TileMxNxK;
+using IREE::Codegen::TileOCxIC;
 
 namespace {
 
@@ -964,6 +965,138 @@
   return result;
 }
 
+/// Builds 9D linalg.generic implementing data-tiled convolution
+/// over already-packed, rank-5/6 operands.
+static linalg::GenericOp
+buildDataTiledConvOp(OpBuilder &builder, Location loc, Value packedInput,
+                     Value packedFilter, Value packedOutput,
+                     ArrayRef<int64_t> strides, ArrayRef<int64_t> dilations) {
+  auto outputType = cast<RankedTensorType>(packedOutput.getType());
+  IREE::Codegen::DataTiledConvIterationSpace space =
+      IREE::Codegen::getDataTiledConvIterationSpace(builder.getContext(),
+                                                    strides, dilations);
+  return linalg::GenericOp::create(
+      builder, loc, outputType,
+      /*inputs=*/ValueRange{packedInput, packedFilter},
+      /*outputs=*/ValueRange{packedOutput}, space.indexingMaps,
+      space.iteratorTypes, [](OpBuilder &b, Location l, ValueRange args) {
+        Value mul = arith::MulFOp::create(b, l, args[0], args[1]);
+        Value add = arith::AddFOp::create(b, l, mul, args[2]);
+        linalg::YieldOp::create(b, l, add);
+      });
+}
+
+/// Lowers a data-tiled convolution whose operands carry conv encodings.
+///
+/// Builds a 9-D linalg.generic over the packed operands:
+///   input:  [N, IC/c0, H, W, c0]
+///   filter: [OC/k0, IC/c0, FH, FW, c0, k0]
+///   output: [N, OC/k0, OH, OW, k0]
+Operation *lowerConvolutionOpWithEncoding(
+    OpBuilder &builder, linalg::LinalgOp linalgOp, ValueRange operands,
+    IREE::Encoding::LayoutMaterializerAttr layoutAttr) {
+  if (!linalgOp.hasPureTensorSemantics()) {
+    return nullptr;
+  }
+
+  auto inputs = linalgOp.getDpsInputOperands();
+  auto outputs = linalgOp.getDpsInits();
+
+  auto encodedInputType = cast<RankedTensorType>(inputs[0]->get().getType());
+  auto encodedFilterType = cast<RankedTensorType>(inputs[1]->get().getType());
+  auto encodedOutputType = cast<RankedTensorType>(outputs[0].getType());
+  auto inEncoding = IREE::Encoding::getEncodingAttr(encodedInputType);
+  auto filterEncoding = IREE::Encoding::getEncodingAttr(encodedFilterType);
+  auto outEncoding = IREE::Encoding::getEncodingAttr(encodedOutputType);
+  if (!inEncoding || !filterEncoding || !outEncoding) {
+    return nullptr;
+  }
+
+  if (inEncoding.getOperandIndex().getValue() != IREE::Encoding::CONV_IN ||
+      filterEncoding.getOperandIndex().getValue() !=
+          IREE::Encoding::CONV_FILTER ||
+      outEncoding.getOperandIndex().getValue() != IREE::Encoding::CONV_OUT) {
+    return nullptr;
+  }
+
+  MaterializeEncodingInfo encodingInfo = {};
+  if (auto packedLayoutAttr =
+          dyn_cast<IREE::Codegen::PackedLayoutMaterializerAttr>(layoutAttr)) {
+    encodingInfo = packedLayoutAttr.getEncodingInfo(
+        cast<RankedTensorType>(linalgOp->getResultTypes()[0]));
+  }
+
+  if (isIdentityLayout(encodingInfo)) {
+    return dropEncodingAndCloneOp(builder, linalgOp,
+                                  operands.take_front(inputs.size()),
+                                  operands.drop_front(inputs.size()));
+  }
+
+  Value packedInput = operands[0];
+  Value packedFilter = operands[1];
+  Value packedOutput = operands[2];
+
+  Location loc = linalgOp.getLoc();
+
+  auto packedInputType = cast<RankedTensorType>(packedInput.getType());
+  auto packedOutputType = cast<RankedTensorType>(packedOutput.getType());
+
+  // Expected packed ranks for a 2D conv: input/output rank-5 ([N, IC/c0, H, W,
+  // c0] and [N, OC/k0, OH, OW, k0]), or rank-4 if the N=1 batch dim has been
+  // folded away upstream, and filter rank-6 ([OC/k0, IC/c0, FH, FW, c0, k0]).
+  const int64_t inRank = packedInputType.getRank();
+  const int64_t outRank = packedOutputType.getRank();
+  const int64_t filterRank =
+      cast<RankedTensorType>(packedFilter.getType()).getRank();
+  if ((inRank != 4 && inRank != 5) || inRank != outRank || filterRank != 6) {
+    return nullptr;
+  }
+
+  // WORKAROUND: When N=1, upstream folding may remove the batch dimension.
+  // Restore it so the remainder of this lowering can assume rank-5 packed
+  // tensors. If batch folding before materialization is eventually prevented,
+  // this expand/collapse workaround can be dropped.
+  auto prependUnitBatchDim = [&](Value src) -> Value {
+    auto srcType = cast<RankedTensorType>(src.getType());
+
+    SmallVector<int64_t, 5> expandedShape = {1};
+    expandedShape.append(srcType.getShape().begin(), srcType.getShape().end());
+
+    SmallVector<ReassociationIndices> ri = {{0, 1}, {2}, {3}, {4}};
+
+    return tensor::ExpandShapeOp::create(
+        builder, loc,
+        RankedTensorType::get(expandedShape, srcType.getElementType()), src,
+        ri);
+  };
+
+  bool batchDimFolded = (inRank == 4);
+  if (batchDimFolded) {
+    // [IC/c0, H, W, c0] -> [1, IC/c0, H, W, c0]
+    packedInput = prependUnitBatchDim(packedInput);
+    // [OC/k0, OH, OW, k0] -> [1, OC/k0, OH, OW, k0]
+    packedOutput = prependUnitBatchDim(packedOutput);
+  }
+
+  auto cDims = linalg::inferConvolutionDims(linalgOp);
+  if (failed(cDims) || cDims->strides.size() != 2 ||
+      cDims->dilations.size() != 2) {
+    return nullptr;
+  }
+
+  Operation *convOp =
+      buildDataTiledConvOp(builder, loc, packedInput, packedFilter,
+                           packedOutput, cDims->strides, cDims->dilations);
+
+  // [1, OC/k0, OH, OW, k0] -> [OC/k0, OH, OW, k0]
+  if (batchDimFolded) {
+    SmallVector<ReassociationIndices> ri = {{0, 1}, {2}, {3}, {4}};
+    return tensor::CollapseShapeOp::create(builder, loc, packedOutputType,
+                                           convOp->getResult(0), ri);
+  }
+  return convOp;
+}
+
 //===----------------------------------------------------------------------===//
 // Interface methods implementation for iree_cpu.cpu_encoding_resolver.
 //===----------------------------------------------------------------------===//
@@ -1345,6 +1478,57 @@
   return {};
 }
 
+// Enumerate (OC, IC) conv inner tile sizes to choose from on arm64.
+static SmallVector<TileOCxIC> enumerateConvTileArm64(TypeRange elementTypes,
+                                                     DictionaryAttr config) {
+  assert(elementTypes.size() == 3 && "expected input, filter, output types");
+  Type inputElem = elementTypes[0];
+  Type filterElem = elementTypes[1];
+  Type outputElem = elementTypes[2];
+  // Only fp32 is currently supported; the tile shape and
+  // the ukernel are specific to 32-bit float lanes.
+  if (inputElem.isF32() && filterElem.isF32() && outputElem.isF32()) {
+    return {TileOCxIC{8, 8}}; // Aim to use FMLA (neon).
+  }
+
+  // Fallback - no architecture-optimized tile size for this case.
+  return {};
+}
+
+// Enumerate (OC, IC) conv inner tile sizes to choose from on x86-64.
+static SmallVector<TileOCxIC> enumerateConvTileX86_64(TypeRange elementTypes,
+                                                      DictionaryAttr config) {
+  assert(elementTypes.size() == 3 && "expected input, filter, output types");
+  Type inputElem = elementTypes[0];
+  Type filterElem = elementTypes[1];
+  Type outputElem = elementTypes[2];
+  // Only fp32 is currently supported; the tile shape and
+  // the ukernel are specific to 32-bit float lanes.
+  if (inputElem.isF32() && filterElem.isF32() && outputElem.isF32()) {
+    if (hasFeature(config, "+avx512f")) {
+      return {TileOCxIC{16, 16}}; // Aim to use VFMADD* (zmm).
+    }
+  }
+
+  // Fallback - no architecture-optimized tile size for this case.
+  return {};
+}
+
+static SmallVector<TileOCxIC>
+enumerateCPUConvTiles(IREE::Encoding::EncodingAttr encoding,
+                      DictionaryAttr config) {
+  SmallVector<Type> elementTypes = encoding.getElementTypesArray();
+  if (isAArch64(config)) {
+    return enumerateConvTileArm64(elementTypes, config);
+  }
+  if (isX86_64(config)) {
+    return enumerateConvTileX86_64(elementTypes, config);
+  }
+
+  // Fallback - no architecture-optimized tile size for this case.
+  return {};
+}
+
 struct CPUEncodingPackedLayoutMaterializerAttr
     : PackedLayoutMaterializerAttrExternalModelBase<
           CPUEncodingPackedLayoutMaterializerAttr, CPUEncodingResolverAttr> {
@@ -1365,6 +1549,30 @@
       return info;
     }
 
+    // Handle convolution encodings.
+    if (encoding.getOpType().getValue() ==
+        IREE::Encoding::EncodingOpType::conv) {
+      FailureOr<linalg::ConvolutionDimensions> cDims =
+          linalg::inferConvolutionDims(encoding.getRootMaps());
+      if (failed(cDims) || !cDims->depth.empty() ||
+          cDims->outputImage.size() != 2 ||
+          llvm::any_of(cDims->dilations, [](int64_t d) { return d != 1; })) {
+        return info; // identity layout
+      }
+      SmallVector<TileOCxIC> tiles =
+          enumerateCPUConvTiles(encoding, layoutAttr.getConfiguration());
+      if (tiles.empty()) {
+        return info;
+      }
+      TileOCxIC tile = tiles[0];
+      FailureOr<MaterializeEncodingInfo> maybeInfo =
+          IREE::Codegen::getEncodingInfoForConv(encoding, tile);
+      if (failed(maybeInfo)) {
+        return info;
+      }
+      return maybeInfo.value();
+    }
+
     // We only know about contractions with {Batch, M, N, K} <= 1 at the moment.
     auto cDims = getEncodingContractionDims(encoding);
     if (failed(cDims) || cDims->batch.size() > 1 || cDims->m.size() > 1 ||
@@ -1496,6 +1704,11 @@
                                     convertedOperands.take_front(numInputs),
                                     convertedOperands.drop_front(numInputs));
     }
+    if (linalg::isaConvolutionOpInterface(linalgOp)) {
+      return lowerConvolutionOpWithEncoding(
+          b, linalgOp, convertedOperands,
+          cast<IREE::Encoding::LayoutMaterializerAttr>(layoutAttr));
+    }
     if (linalg::isaContractionOpInterface(linalgOp)) {
       DictionaryAttr config = layoutAttr.getConfiguration();
       if (getEnableInnerTiledFromConfig(config)) {
diff --git a/compiler/src/iree/compiler/Dialect/Stream/Transforms/test/specialize_encodings.mlir b/compiler/src/iree/compiler/Dialect/Stream/Transforms/test/specialize_encodings.mlir
index f993c71..36b0e06 100644
--- a/compiler/src/iree/compiler/Dialect/Stream/Transforms/test/specialize_encodings.mlir
+++ b/compiler/src/iree/compiler/Dialect/Stream/Transforms/test/specialize_encodings.mlir
@@ -40,6 +40,39 @@
 
 // -----
 
+// Encoding layout specialization for an nhwc_hwcf convolution.
+
+#conv_in  = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (n, oh + fh, ow + fw, ic)>
+#conv_filter = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (fh, fw, ic, oc)>
+#conv_out = affine_map<(n, oh, ow, oc, fh, fw, ic) -> (n, oh, ow, oc)>
+#executable_target_x86_64 = #hal.executable.target<"llvm-cpu", "xyz", {iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>, target_triple="x86_64-xyz-xyz", cpu_features="+avx512f"}>
+#executable_target_aarch64 = #hal.executable.target<"llvm-cpu", "xyz", {iree.encoding.resolver = #iree_cpu.cpu_encoding_resolver<>, target_triple="aarch64-xyz-xyz", cpu_features="+neon"}>
+#device_target_local_0_ = #hal.device.target<"local", {ordinal = 0 : index}, [#executable_target_x86_64]> : !hal.device
+#device_target_local_1_ = #hal.device.target<"local", {ordinal = 1 : index}, [#executable_target_aarch64]> : !hal.device
+#conv_encoding_input  = #iree_encoding.encoding<operand_index = 0 : index, op_type = conv, element_types = [f32, f32, f32], user_indexing_maps = [#conv_in, #conv_filter, #conv_out], iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+#conv_encoding_filter = #iree_encoding.encoding<operand_index = 1 : index, op_type = conv, element_types = [f32, f32, f32], user_indexing_maps = [#conv_in, #conv_filter, #conv_out], iteration_sizes = [1, 14, 14, 8, 3, 3, 4]>
+
+util.global private @device_a = #device_target_local_0_
+util.global private @device_b = #device_target_local_1_
+util.func public @conv_tensor_sizeof() -> (index, index, index, index) {
+  %size0 = stream.tensor.sizeof on(#hal.device.affinity<@device_a>) tensor<1x16x16x4xf32, #conv_encoding_input>  : index
+  %size1 = stream.tensor.sizeof on(#hal.device.affinity<@device_a>) tensor<3x3x4x8xf32, #conv_encoding_filter> : index
+  %size2 = stream.tensor.sizeof on(#hal.device.affinity<@device_b>) tensor<1x16x16x4xf32, #conv_encoding_input>  : index
+  %size3 = stream.tensor.sizeof on(#hal.device.affinity<@device_b>) tensor<3x3x4x8xf32, #conv_encoding_filter> : index
+  util.return %size0, %size1, %size2, %size3 : index, index, index, index
+}
+// CHECK-DAG:   #[[$IN_X86:.+]] = #iree_encoding.layout<[#iree_cpu.cpu_encoding_resolver{{.+}}encoding_info = {innerDimsPos = [3], innerTileSizes = [16], outerDimsPerm = [0, 3, 1, 2]}
+// CHECK-DAG:   #[[$FLT_X86:.+]] = #iree_encoding.layout<[#iree_cpu.cpu_encoding_resolver{{.+}}encoding_info = {innerDimsPos = [2, 3], innerTileSizes = [16, 16], outerDimsPerm = [3, 2, 0, 1]}
+// CHECK-DAG:   #[[$IN_ARM:.+]] = #iree_encoding.layout<[#iree_cpu.cpu_encoding_resolver{{.+}}encoding_info = {innerDimsPos = [3], innerTileSizes = [8], outerDimsPerm = [0, 3, 1, 2]}
+// CHECK-DAG:   #[[$FLT_ARM:.+]] = #iree_encoding.layout<[#iree_cpu.cpu_encoding_resolver{{.+}}encoding_info = {innerDimsPos = [2, 3], innerTileSizes = [8, 8], outerDimsPerm = [3, 2, 0, 1]}
+// CHECK-LABEL: util.func public @conv_tensor_sizeof
+// CHECK:         stream.tensor.sizeof {{.+}} tensor<1x16x16x4xf32, #[[$IN_X86]]>
+// CHECK:         stream.tensor.sizeof {{.+}} tensor<3x3x4x8xf32, #[[$FLT_X86]]>
+// CHECK:         stream.tensor.sizeof {{.+}} tensor<1x16x16x4xf32, #[[$IN_ARM]]>
+// CHECK:         stream.tensor.sizeof {{.+}} tensor<3x3x4x8xf32, #[[$FLT_ARM]]>
+
+// -----
+
 //------------------------------------------------------------------------------
 // #iree_gpu.gpu_encoding_resolver specialization tests.
 // These get serialized to the layout attributes.