[Runtime] Add initial data-tiled conv ukernel (generic & avx512) (#24729)

Adds a data-tiled convolution microkernel with a generic implementation
and an avx512 optimized tile kernel with inner tile sizes (OW_TILE=16,
K0=16, C0=16).

Layouts (c0 = inner IC block, k0 = inner OC block):
    input [N, IC/c0, H, W, c0]
    filter [OC/k0, IC/c0, FH, FW, c0, k0]
    output [N, OC/k0, OH, OW, k0]

The ABI uses explicit outer-dimension strides. These strides refer to
the full parent tensor layout rather than the extracted slab, ensuring
correct addressing when kernels operate on subviews of the tensor. Only
the inner W/c0/k0 dims are contiguous.

Scope: f32 only; dilation=1 (dilated convs are not matched by the
dispatch); requires the packed NCHWc layout, with the arch path selected
only for k0=c0=16. Additional types and tile shapes are future work.

---------

Signed-off-by: Pooja Hemashekar <hemashekar@roofline.ai>
diff --git a/runtime/src/iree/builtins/ukernel/BUILD.bazel b/runtime/src/iree/builtins/ukernel/BUILD.bazel
index 22448d4..2a9efb4 100644
--- a/runtime/src/iree/builtins/ukernel/BUILD.bazel
+++ b/runtime/src/iree/builtins/ukernel/BUILD.bazel
@@ -21,6 +21,8 @@
 
 internal_headers = [
     "common.h",
+    "conv_nchwc.h",
+    "conv_nchwc_internal.h",
     "exported_bits.h",
     "mmt4d.h",
     "mmt4d_internal.h",
@@ -58,6 +60,8 @@
 iree_runtime_cc_library(
     name = "ukernel",
     srcs = [
+        "conv_nchwc.c",
+        "conv_nchwc_tile_generic.c",
         "mmt4d.c",
         "mmt4d_tile_generic.c",
         "pack.c",
@@ -112,6 +116,8 @@
 [iree_bitcode_library(
     name = "ukernel_bitcode_generic_%s" % arch,
     srcs = [
+        "conv_nchwc.c",
+        "conv_nchwc_tile_generic.c",
         "mmt4d.c",
         "mmt4d_tile_generic.c",
         "pack.c",
diff --git a/runtime/src/iree/builtins/ukernel/CMakeLists.txt b/runtime/src/iree/builtins/ukernel/CMakeLists.txt
index 1689c49..ba8429a 100644
--- a/runtime/src/iree/builtins/ukernel/CMakeLists.txt
+++ b/runtime/src/iree/builtins/ukernel/CMakeLists.txt
@@ -33,6 +33,8 @@
     COMMAND ${CMAKE_COMMAND} -E touch internal_headers_filegroup.stamp
   DEPENDS
     "common.h"
+    "conv_nchwc.h"
+    "conv_nchwc_internal.h"
     "exported_bits.h"
     "mmt4d.h"
     "mmt4d_internal.h"
@@ -53,6 +55,8 @@
     internal_headers
   HDRS
     "common.h"
+    "conv_nchwc.h"
+    "conv_nchwc_internal.h"
     "exported_bits.h"
     "mmt4d.h"
     "mmt4d_internal.h"
@@ -72,6 +76,8 @@
     fallback
   HDRS
     "common.h"
+    "conv_nchwc.h"
+    "conv_nchwc_internal.h"
     "exported_bits.h"
     "mmt4d.h"
     "mmt4d_internal.h"
@@ -95,6 +101,10 @@
     "api.h"
   SRCS
     "common.h"
+    "conv_nchwc.c"
+    "conv_nchwc.h"
+    "conv_nchwc_internal.h"
+    "conv_nchwc_tile_generic.c"
     "exported_bits.h"
     "mmt4d.c"
     "mmt4d.h"
@@ -129,6 +139,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "internal_headers_filegroup.stamp"
   SRCS
+    "conv_nchwc.c"
+    "conv_nchwc_tile_generic.c"
     "mmt4d.c"
     "mmt4d_tile_generic.c"
     "pack.c"
@@ -146,6 +158,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "internal_headers_filegroup.stamp"
   SRCS
+    "conv_nchwc.c"
+    "conv_nchwc_tile_generic.c"
     "mmt4d.c"
     "mmt4d_tile_generic.c"
     "pack.c"
@@ -163,6 +177,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "internal_headers_filegroup.stamp"
   SRCS
+    "conv_nchwc.c"
+    "conv_nchwc_tile_generic.c"
     "fallback.c"
     "mmt4d.c"
     "mmt4d_tile_generic.c"
@@ -181,6 +197,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "internal_headers_filegroup.stamp"
   SRCS
+    "conv_nchwc.c"
+    "conv_nchwc_tile_generic.c"
     "mmt4d.c"
     "mmt4d_tile_generic.c"
     "pack.c"
@@ -198,6 +216,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "internal_headers_filegroup.stamp"
   SRCS
+    "conv_nchwc.c"
+    "conv_nchwc_tile_generic.c"
     "fallback.c"
     "mmt4d.c"
     "mmt4d_tile_generic.c"
diff --git a/runtime/src/iree/builtins/ukernel/api.h b/runtime/src/iree/builtins/ukernel/api.h
index a6584b3..b557d00 100644
--- a/runtime/src/iree/builtins/ukernel/api.h
+++ b/runtime/src/iree/builtins/ukernel/api.h
@@ -7,6 +7,7 @@
 #ifndef IREE_BUILTINS_UKERNEL_API_H_
 #define IREE_BUILTINS_UKERNEL_API_H_
 
+#include "iree/builtins/ukernel/conv_nchwc.h"
 #include "iree/builtins/ukernel/mmt4d.h"
 #include "iree/builtins/ukernel/pack.h"
 #include "iree/builtins/ukernel/query_tile_sizes.h"
diff --git a/runtime/src/iree/builtins/ukernel/arch/arm_64/BUILD.bazel b/runtime/src/iree/builtins/ukernel/arch/arm_64/BUILD.bazel
index 182b9b3..2492d0d 100644
--- a/runtime/src/iree/builtins/ukernel/arch/arm_64/BUILD.bazel
+++ b/runtime/src/iree/builtins/ukernel/arch/arm_64/BUILD.bazel
@@ -39,6 +39,7 @@
 iree_bitcode_library(
     name = "ukernel_bitcode_arch_arm_64_entry_points",
     srcs = [
+        "conv_nchwc_arm_64_entry_point.c",
         "mmt4d_arm_64_entry_point.c",
         "pack_arm_64_entry_point.c",
         "unpack_arm_64_entry_point.c",
diff --git a/runtime/src/iree/builtins/ukernel/arch/arm_64/CMakeLists.txt b/runtime/src/iree/builtins/ukernel/arch/arm_64/CMakeLists.txt
index c10a595..72d2f1c 100644
--- a/runtime/src/iree/builtins/ukernel/arch/arm_64/CMakeLists.txt
+++ b/runtime/src/iree/builtins/ukernel/arch/arm_64/CMakeLists.txt
@@ -27,6 +27,7 @@
     "pack_arm_64_internal.h"
     "unpack_arm_64_internal.h"
   SRCS
+    "conv_nchwc_arm_64_entry_point.c"
     "mmt4d_arm_64_entry_point.c"
     "pack_arm_64_entry_point.c"
     "unpack_arm_64_entry_point.c"
@@ -288,6 +289,7 @@
   NAME
     arm_64
   SRCS
+    "conv_nchwc_arm_64_entry_point.c"
     "mmt4d_arm_64_entry_point.c"
     "mmt4d_arm_64_base.c"
     "pack_arm_64_entry_point.c"
diff --git a/runtime/src/iree/builtins/ukernel/arch/arm_64/conv_nchwc_arm_64_entry_point.c b/runtime/src/iree/builtins/ukernel/arch/arm_64/conv_nchwc_arm_64_entry_point.c
new file mode 100644
index 0000000..dc9ef7d
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/arch/arm_64/conv_nchwc_arm_64_entry_point.c
@@ -0,0 +1,17 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+// No arm_64-specific conv_nchwc tile function yet. Falls back to generic.
+// TODO: implement arm_64 tile functions analogous to the +dotprod / +i8mm /
+// +bf16 dispatch in mmt4d_arm_64_entry_point.c.
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_arch(
+    const iree_uk_conv_nchwc_params_t* params) {
+  (void)params;
+  iree_uk_conv_nchwc_tile_selection_t selection = {0};
+  return selection;
+}
diff --git a/runtime/src/iree/builtins/ukernel/arch/riscv_64/BUILD.bazel b/runtime/src/iree/builtins/ukernel/arch/riscv_64/BUILD.bazel
index b41e2f4..02dae9e 100644
--- a/runtime/src/iree/builtins/ukernel/arch/riscv_64/BUILD.bazel
+++ b/runtime/src/iree/builtins/ukernel/arch/riscv_64/BUILD.bazel
@@ -39,6 +39,7 @@
 iree_bitcode_library(
     name = "ukernel_bitcode_arch_riscv_64_entry_points",
     srcs = [
+        "conv_nchwc_riscv_64_entry_point.c",
         "mmt4d_riscv_64_entry_point.c",
         "pack_riscv_64_entry_point.c",
         "unpack_riscv_64_entry_point.c",
diff --git a/runtime/src/iree/builtins/ukernel/arch/riscv_64/CMakeLists.txt b/runtime/src/iree/builtins/ukernel/arch/riscv_64/CMakeLists.txt
index 7cd59ef..f155c06 100644
--- a/runtime/src/iree/builtins/ukernel/arch/riscv_64/CMakeLists.txt
+++ b/runtime/src/iree/builtins/ukernel/arch/riscv_64/CMakeLists.txt
@@ -27,6 +27,7 @@
     "pack_riscv_64_internal.h"
     "unpack_riscv_64_internal.h"
   SRCS
+    "conv_nchwc_riscv_64_entry_point.c"
     "mmt4d_riscv_64_entry_point.c"
     "pack_riscv_64_entry_point.c"
     "unpack_riscv_64_entry_point.c"
@@ -259,6 +260,7 @@
   NAME
     riscv_64
   SRCS
+    "conv_nchwc_riscv_64_entry_point.c"
     "mmt4d_riscv_64_entry_point.c"
     "pack_riscv_64_entry_point.c"
     "unpack_riscv_64_entry_point.c"
diff --git a/runtime/src/iree/builtins/ukernel/arch/riscv_64/conv_nchwc_riscv_64_entry_point.c b/runtime/src/iree/builtins/ukernel/arch/riscv_64/conv_nchwc_riscv_64_entry_point.c
new file mode 100644
index 0000000..6860e6e
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/arch/riscv_64/conv_nchwc_riscv_64_entry_point.c
@@ -0,0 +1,17 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+// No riscv_64-specific conv_nchwc tile function yet. Falls back to generic.
+// TODO: implement riscv_64 tile functions analogous to the +v / +zvfh /
+// +zvfhmin dispatch in mmt4d_riscv_64_entry_point.c.
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_arch(
+    const iree_uk_conv_nchwc_params_t* params) {
+  (void)params;
+  iree_uk_conv_nchwc_tile_selection_t selection = {0};
+  return selection;
+}
diff --git a/runtime/src/iree/builtins/ukernel/arch/x86_64/BUILD.bazel b/runtime/src/iree/builtins/ukernel/arch/x86_64/BUILD.bazel
index f487707..28f8a6f 100644
--- a/runtime/src/iree/builtins/ukernel/arch/x86_64/BUILD.bazel
+++ b/runtime/src/iree/builtins/ukernel/arch/x86_64/BUILD.bazel
@@ -28,6 +28,8 @@
 # All headers transitively included by code in this directory. Bazel-only.
 UKERNEL_X86_64_INTERNAL_HEADERS = [
     "common_x86_64.h",
+    "conv_nchwc_x86_64_internal.h",
+    "conv_nchwc_x86_64_tiles.inl",
     "mmt4d_x86_64_internal.h",
     "mmt4d_x86_64_tiles.inl",
     "pack_x86_64_internal.h",
@@ -39,6 +41,7 @@
 iree_bitcode_library(
     name = "ukernel_bitcode_arch_x86_64_entry_points",
     srcs = [
+        "conv_nchwc_x86_64_entry_point.c",
         "mmt4d_x86_64_entry_point.c",
         "pack_x86_64_entry_point.c",
         "unpack_x86_64_entry_point.c",
@@ -77,6 +80,7 @@
 iree_bitcode_library(
     name = "ukernel_bitcode_arch_x86_64_avx512_base",
     srcs = [
+        "conv_nchwc_x86_64_avx512_base.c",
         "mmt4d_x86_64_avx512_base.c",
         "pack_x86_64_avx512_base.c",
         "unpack_x86_64_avx512_base.c",
diff --git a/runtime/src/iree/builtins/ukernel/arch/x86_64/CMakeLists.txt b/runtime/src/iree/builtins/ukernel/arch/x86_64/CMakeLists.txt
index 63d1376..ec84021 100644
--- a/runtime/src/iree/builtins/ukernel/arch/x86_64/CMakeLists.txt
+++ b/runtime/src/iree/builtins/ukernel/arch/x86_64/CMakeLists.txt
@@ -22,11 +22,14 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/builtins/ukernel/internal_headers_filegroup.stamp"
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "common_x86_64.h"
+    "conv_nchwc_x86_64_internal.h"
+    "conv_nchwc_x86_64_tiles.inl"
     "mmt4d_x86_64_internal.h"
     "mmt4d_x86_64_tiles.inl"
     "pack_x86_64_internal.h"
     "unpack_x86_64_internal.h"
   SRCS
+    "conv_nchwc_x86_64_entry_point.c"
     "mmt4d_x86_64_entry_point.c"
     "pack_x86_64_entry_point.c"
     "unpack_x86_64_entry_point.c"
@@ -41,6 +44,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/builtins/ukernel/internal_headers_filegroup.stamp"
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "common_x86_64.h"
+    "conv_nchwc_x86_64_internal.h"
+    "conv_nchwc_x86_64_tiles.inl"
     "mmt4d_x86_64_internal.h"
     "mmt4d_x86_64_tiles.inl"
     "pack_x86_64_internal.h"
@@ -65,11 +70,14 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/builtins/ukernel/internal_headers_filegroup.stamp"
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "common_x86_64.h"
+    "conv_nchwc_x86_64_internal.h"
+    "conv_nchwc_x86_64_tiles.inl"
     "mmt4d_x86_64_internal.h"
     "mmt4d_x86_64_tiles.inl"
     "pack_x86_64_internal.h"
     "unpack_x86_64_internal.h"
   SRCS
+    "conv_nchwc_x86_64_avx512_base.c"
     "mmt4d_x86_64_avx512_base.c"
     "pack_x86_64_avx512_base.c"
     "unpack_x86_64_avx512_base.c"
@@ -94,6 +102,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/builtins/ukernel/internal_headers_filegroup.stamp"
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "common_x86_64.h"
+    "conv_nchwc_x86_64_internal.h"
+    "conv_nchwc_x86_64_tiles.inl"
     "mmt4d_x86_64_internal.h"
     "mmt4d_x86_64_tiles.inl"
     "pack_x86_64_internal.h"
@@ -122,6 +132,8 @@
     "${PROJECT_BINARY_DIR}/runtime/src/iree/builtins/ukernel/internal_headers_filegroup.stamp"
     "${PROJECT_BINARY_DIR}/runtime/src/iree/schemas/cpu_data_headers_filegroup.stamp"
     "common_x86_64.h"
+    "conv_nchwc_x86_64_internal.h"
+    "conv_nchwc_x86_64_tiles.inl"
     "mmt4d_x86_64_internal.h"
     "mmt4d_x86_64_tiles.inl"
     "pack_x86_64_internal.h"
@@ -367,6 +379,7 @@
   NAME
     x86_64_avx512_base
   SRCS
+    "conv_nchwc_x86_64_avx512_base.c"
     "mmt4d_x86_64_avx512_base.c"
     "pack_x86_64_avx512_base.c"
     "unpack_x86_64_avx512_base.c"
@@ -410,6 +423,7 @@
   NAME
     x86_64
   SRCS
+    "conv_nchwc_x86_64_entry_point.c"
     "mmt4d_x86_64_entry_point.c"
     "pack_x86_64_entry_point.c"
     "query_tile_sizes_x86_64_entry_point.c"
diff --git a/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_avx512_base.c b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_avx512_base.c
new file mode 100644
index 0000000..7b5f61e
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_avx512_base.c
@@ -0,0 +1,118 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/builtins/ukernel/arch/x86_64/common_x86_64.h"
+#include "iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_internal.h"
+
+// AVX-512 tile kernel for data-tiled NCHWc conv (f32 x f32 -> f32).
+//
+// 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]
+//
+// Computes one output panel of shape [OW_TILE=16, k0=16] per call.
+//
+// Strategy:
+//   - 16 ZMM accumulators, one per output-width position, each holding a
+//   k0-wide OC block.
+//   - For each (ic_outer, fh, fw, ci):
+//       load one ZMM containing k0 output-channel values for fixed (ic_outer,
+//       fh, fw, ci), broadcast scalar input value at (ow, fw, ci) and FMA into
+//       each OW accumulator.
+//
+// Tile shapes: OW_TILE=16, k0=16, c0=16.
+#define OW_TILE 16
+#define K0 16
+#define C0 16
+
+IREE_UK_ATTRIBUTE_ALWAYS_INLINE static inline void
+iree_uk_conv_nchwc_tile_f32f32f32_16x16x16_x86_64_avx512_base_impl(
+    float* IREE_UK_RESTRICT out_ptr, const float* IREE_UK_RESTRICT in_ptr,
+    const float* IREE_UK_RESTRICT f_ptr,
+    const iree_uk_conv_nchwc_params_t* params, iree_uk_index_t ow_count) {
+  const iree_uk_index_t IC_outer = params->IC_outer;
+  const iree_uk_index_t FH = params->FH;
+  const iree_uk_index_t FW = params->FW;
+  const iree_uk_index_t in_pos_stride = (iree_uk_index_t)params->stride_w * C0;
+  const iree_uk_index_t in_stride_h = params->input_stride_h;
+  const iree_uk_index_t in_stride_ic_outer = params->input_stride_ic_outer;
+  const iree_uk_index_t f_stride_fw = (iree_uk_index_t)C0 * K0;
+  const iree_uk_index_t f_stride_fh = params->filter_stride_fh;
+  const iree_uk_index_t f_stride_ic_outer = params->filter_stride_ic_outer;
+
+  __m512 acc[OW_TILE];
+  if (params->flags & IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE) {
+    IREE_UK_UNROLL for (int i = 0; i < OW_TILE; ++i) {
+      if (i < ow_count) {
+        acc[i] = _mm512_loadu_ps(out_ptr + i * K0);
+      } else {
+        acc[i] = _mm512_setzero_ps();
+      }
+    }
+  } else {
+    IREE_UK_UNROLL for (int i = 0; i < OW_TILE; ++i) {
+      acc[i] = _mm512_setzero_ps();
+    }
+  }
+
+  for (iree_uk_index_t ic_o = 0; ic_o < IC_outer; ++ic_o) {
+    const float* in_ic = in_ptr + ic_o * in_stride_ic_outer;
+    const float* f_ic = f_ptr + ic_o * f_stride_ic_outer;
+
+    for (iree_uk_index_t fh = 0; fh < FH; ++fh) {
+      const float* in_h = in_ic + fh * in_stride_h;
+      const float* f_h = f_ic + fh * f_stride_fh;
+
+      for (iree_uk_index_t fw = 0; fw < FW; ++fw) {
+        const float* f_fw = f_h + fw * f_stride_fw;
+        const float* in_fw = in_h + fw * C0;
+        for (int ci = 0; ci < C0; ++ci) {
+          // Load filter[ci, 0:k0].
+          __m512 f_vec = _mm512_loadu_ps(f_fw + ci * K0);
+
+          // Broadcast input[..., ci] for each output position and FMA.
+          IREE_UK_UNROLL for (int i = 0; i < OW_TILE; ++i) {
+            if (i < ow_count) {
+              __m512 in_bc = _mm512_set1_ps(in_fw[i * in_pos_stride + ci]);
+              acc[i] = _mm512_fmadd_ps(in_bc, f_vec, acc[i]);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  IREE_UK_UNROLL for (int i = 0; i < OW_TILE; ++i) {
+    if (i < ow_count) {
+      _mm512_storeu_ps(out_ptr + i * K0, acc[i]);
+    }
+  }
+}
+
+void iree_uk_conv_nchwc_tile_f32f32f32_16x16x16_x86_64_avx512_base(
+    void* IREE_UK_RESTRICT output_panel,
+    const void* IREE_UK_RESTRICT input_panel,
+    const void* IREE_UK_RESTRICT filter_panel,
+    const iree_uk_conv_nchwc_params_t* params) {
+  IREE_UK_ASSERT(params->ow_count > 0 && params->ow_count <= OW_TILE);
+  float* IREE_UK_RESTRICT out_ptr = (float*)output_panel;
+  const float* IREE_UK_RESTRICT in_ptr = (const float*)input_panel;
+  const float* IREE_UK_RESTRICT f_ptr = (const float*)filter_panel;
+  // TODO(phemashekar): adopt the "always pad" rule (ow_count == OW_TILE always)
+  // to delete the tail path entirely.
+  if (params->ow_count == OW_TILE) {
+    iree_uk_conv_nchwc_tile_f32f32f32_16x16x16_x86_64_avx512_base_impl(
+        out_ptr, in_ptr, f_ptr, params, OW_TILE);
+  } else {
+    iree_uk_conv_nchwc_tile_f32f32f32_16x16x16_x86_64_avx512_base_impl(
+        out_ptr, in_ptr, f_ptr, params, params->ow_count);
+  }
+}
+
+#undef OW_TILE
+#undef K0
+#undef C0
diff --git a/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_entry_point.c b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_entry_point.c
new file mode 100644
index 0000000..925a835
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_entry_point.c
@@ -0,0 +1,44 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/builtins/ukernel/arch/x86_64/common_x86_64.h"
+#include "iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_internal.h"
+
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_arch(
+    const iree_uk_conv_nchwc_params_t* params) {
+  IREE_UK_ATTRIBUTE_UNUSED iree_uk_conv_nchwc_type_t type =
+      iree_uk_conv_nchwc_type(params->flags);
+  iree_uk_conv_nchwc_tile_selection_t selection = {0};
+
+#define IREE_UK_CONV_NCHWC_TILE_IMPL_x86_64(LHS, RHS, OUT, OW_TILE, K0_TILE,                          \
+                                            C0_TILE, SUFFIX)                                          \
+  if (type == iree_uk_conv_nchwc_type_##LHS##RHS##OUT &&                                              \
+      params->k0 == (K0_TILE) && params->c0 == (C0_TILE) &&                                           \
+      iree_uk_cpu_x86_64##SUFFIX(params->cpu_data)) {                                                 \
+    selection.tile_func =                                                                             \
+        iree_uk_conv_nchwc_tile_##LHS##RHS##OUT##_##OW_TILE##x##K0_TILE##x##C0_TILE##_x86_64##SUFFIX; \
+    selection.ow_tile = (OW_TILE);                                                                    \
+  }
+
+#ifdef IREE_UK_BUILD_X86_64_AVX512_BASE
+#define IREE_UK_CONV_NCHWC_TILE_x86_64_avx512_base(LHS, RHS, OUT, OW_TILE, \
+                                                   K0_TILE, C0_TILE)       \
+  IREE_UK_CONV_NCHWC_TILE_IMPL_x86_64(LHS, RHS, OUT, OW_TILE, K0_TILE,     \
+                                      C0_TILE, _avx512_base)
+#else
+#define IREE_UK_CONV_NCHWC_TILE_x86_64_avx512_base(LHS, RHS, OUT, OW_TILE, \
+                                                   K0_TILE, C0_TILE)
+#endif
+
+#define IREE_UK_CONV_NCHWC_TILE(ARCH, LHS, RHS, OUT, OW_TILE, K0_TILE,    \
+                                C0_TILE, SUFFIX)                          \
+  IREE_UK_CONV_NCHWC_TILE_x86_64##SUFFIX(LHS, RHS, OUT, OW_TILE, K0_TILE, \
+                                         C0_TILE)
+
+#include "iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_tiles.inl"
+
+  return selection;
+}
diff --git a/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_internal.h b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_internal.h
new file mode 100644
index 0000000..c1a29e5
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_internal.h
@@ -0,0 +1,24 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef IREE_BUILTINS_UKERNEL_ARCH_X86_64_CONV_NCHWC_X86_64_INTERNAL_H_
+#define IREE_BUILTINS_UKERNEL_ARCH_X86_64_CONV_NCHWC_X86_64_INTERNAL_H_
+
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+
+#define IREE_UK_CONV_NCHWC_TILE(ARCH, LHS, RHS, OUT, OW_TILE, K0, C0, SUFFIX)         \
+  void                                                                                \
+  iree_uk_conv_nchwc_tile_##LHS##RHS##OUT##_##OW_TILE##x##K0##x##C0##_##ARCH##SUFFIX( \
+      void* IREE_UK_RESTRICT output_panel,                                            \
+      const void* IREE_UK_RESTRICT input_panel,                                       \
+      const void* IREE_UK_RESTRICT filter_panel,                                      \
+      const iree_uk_conv_nchwc_params_t* params);
+
+#include "iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_tiles.inl"
+
+#undef IREE_UK_CONV_NCHWC_TILE
+
+#endif  // IREE_BUILTINS_UKERNEL_ARCH_X86_64_CONV_NCHWC_X86_64_INTERNAL_H_
diff --git a/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_tiles.inl b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_tiles.inl
new file mode 100644
index 0000000..e0a47b2
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/arch/x86_64/conv_nchwc_x86_64_tiles.inl
@@ -0,0 +1,11 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+// Params: ARCH, INPUT_T, FILTER_T, OUTPUT_T, OW_TILE, K0, C0, SUFFIX
+//
+// AVX-512 provides a single f32 tile config for now:
+// OW_TILE=16, K0=16, C0=16
+IREE_UK_CONV_NCHWC_TILE(x86_64, f32, f32, f32, 16, 16, 16, _avx512_base)
diff --git a/runtime/src/iree/builtins/ukernel/conv_nchwc.c b/runtime/src/iree/builtins/ukernel/conv_nchwc.c
new file mode 100644
index 0000000..68d101d
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/conv_nchwc.c
@@ -0,0 +1,219 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/builtins/ukernel/conv_nchwc.h"
+
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+#include "iree/builtins/ukernel/exported_bits.h"
+
+static void iree_uk_conv_nchwc_validate(
+    const iree_uk_conv_nchwc_params_t* params) {
+#ifdef IREE_UK_ENABLE_ASSERTS
+  const iree_uk_uint32_t allflags =
+      IREE_UK_FLAG_CONV_NCHWC_TYPE_MASK | IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE |
+      IREE_UK_FLAG_CONV_NCHWC_ALLOW_GENERIC_FALLBACK_TILE_FUNCTION;
+  IREE_UK_ASSERT(!(params->flags & ~allflags));
+  const iree_uk_uint32_t type_bits =
+      params->flags & IREE_UK_FLAG_CONV_NCHWC_TYPE_MASK;
+  IREE_UK_ASSERT(type_bits > 0 && type_bits < IREE_UK_FLAG_CONV_NCHWC_TYPE_END);
+  // Bound shape dims to a narrower int32 range here. This can be
+  // relaxed later if needed.
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->N, 31));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->OC_outer, 31));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->OH, 31));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->OW, 31));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->IC_outer, 31));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->FH, 31));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->FW, 31));
+  // int32 is overkill for tile sizes; enforce int16 range for now.
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->k0, 15));
+  IREE_UK_ASSERT(IREE_UK_VALUE_IN_UNSIGNED_INT_RANGE(params->c0, 15));
+  IREE_UK_ASSERT(params->k0 > 0 && params->c0 > 0);
+  IREE_UK_ASSERT(params->stride_h > 0 && params->stride_w > 0);
+#endif
+}
+
+static bool iree_uk_conv_nchwc_early(
+    const iree_uk_conv_nchwc_params_t* params) {
+  if (params->N == 0 || params->OC_outer == 0 || params->OH == 0 ||
+      params->OW == 0) {
+    return true;
+  }
+  if ((params->IC_outer == 0 || params->FH == 0 || params->FW == 0) &&
+      (params->flags & IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE)) {
+    return true;
+  }
+  return false;
+}
+
+// Shared outer driver for conv2d tile implementation.
+//
+// The tile function is called once per output panel of shape [OW_TILE, k0].
+// It receives:
+//   - output_panel: pointer to output[n, oc_outer, oh, ow, 0]  (k0 inner)
+//   - input_panel:  pointer to input[n, 0, oh x sH, ow x sW, 0]
+//   - filter_panel: pointer to filter[oc_outer, 0, 0, 0, 0, 0]
+//   - params:      strides + shape info
+//
+// The tile function internally handles the reduction over (ic_outer, fh, fw,
+// c_inner) and honors the ACCUMULATE flag for the whole call.
+static void iree_uk_conv_nchwc_using_tile_func(
+    const iree_uk_conv_nchwc_params_t* params,
+    iree_uk_conv_nchwc_tile_func_t tile_func, iree_uk_int32_t OW_TILE) {
+  IREE_UK_ASSERT(OW_TILE > 0);
+  const iree_uk_index_t N = params->N;
+  const iree_uk_index_t OC_outer = params->OC_outer;
+  const iree_uk_index_t OH = params->OH;
+  const iree_uk_index_t OW = params->OW;
+  const iree_uk_int32_t k0 = params->k0;
+  const iree_uk_int32_t c0 = params->c0;
+  const iree_uk_int32_t stride_h = params->stride_h;
+  const iree_uk_int32_t stride_w = params->stride_w;
+  const iree_uk_index_t input_stride_n = params->input_stride_n;
+  const iree_uk_index_t input_stride_h = params->input_stride_h;
+  const iree_uk_index_t filter_stride_oc_outer = params->filter_stride_oc_outer;
+  const iree_uk_index_t output_stride_n = params->output_stride_n;
+  const iree_uk_index_t output_stride_oc_outer = params->output_stride_oc_outer;
+  const iree_uk_index_t output_stride_oh = params->output_stride_oh;
+
+  const iree_uk_conv_nchwc_type_t type = iree_uk_conv_nchwc_type(params->flags);
+  const iree_uk_index_t input_elem_size =
+      iree_uk_type_size(iree_uk_conv_nchwc_input_type(type));
+  const iree_uk_index_t filter_elem_size =
+      iree_uk_type_size(iree_uk_conv_nchwc_filter_type(type));
+  const iree_uk_index_t out_elem_size =
+      iree_uk_type_size(iree_uk_conv_nchwc_output_type(type));
+
+  const char* input_base = (const char*)params->input_buffer +
+                           params->input_offset * input_elem_size;
+  const char* filter_base = (const char*)params->filter_buffer +
+                            params->filter_offset * filter_elem_size;
+  char* output_base =
+      (char*)params->output_buffer + params->output_offset * out_elem_size;
+
+  iree_uk_conv_nchwc_params_t call_params = *params;
+
+  for (iree_uk_index_t n = 0; n < N; ++n) {
+    const char* in_n = input_base + n * input_stride_n * input_elem_size;
+    char* out_n = output_base + n * output_stride_n * out_elem_size;
+
+    for (iree_uk_index_t oc_o = 0; oc_o < OC_outer; ++oc_o) {
+      const char* f_panel =
+          filter_base + oc_o * filter_stride_oc_outer * filter_elem_size;
+      char* out_oc = out_n + oc_o * output_stride_oc_outer * out_elem_size;
+
+      for (iree_uk_index_t oh = 0; oh < OH; ++oh) {
+        char* out_oh = out_oc + oh * output_stride_oh * out_elem_size;
+        iree_uk_index_t ih = oh * stride_h;
+        const char* in_oh = in_n + ih * input_stride_h * input_elem_size;
+
+        for (iree_uk_index_t ow = 0; ow < OW; ow += OW_TILE) {
+          iree_uk_index_t ow_count = OW_TILE;
+          if (ow + OW_TILE > OW) {
+            ow_count = OW - ow;
+          }
+          char* out_panel = out_oh + ow * k0 * out_elem_size;
+          iree_uk_index_t iw = ow * stride_w;
+          const char* in_panel = in_oh + iw * c0 * input_elem_size;
+
+          // Active output width for this call; equals OW_TILE except on the
+          // right-edge tail.
+          call_params.ow_count = ow_count;
+          tile_func(out_panel, in_panel, f_panel, &call_params);
+        }
+      }
+    }
+  }
+}
+
+void iree_uk_conv_nchwc_p(const iree_uk_conv_nchwc_params_t* params) {
+  iree_uk_conv_nchwc_validate(params);
+
+  // Return early without selecting a tile function in trivial cases.
+  if (iree_uk_conv_nchwc_early(params)) return;
+
+  // Select a target specific tile function.
+  iree_uk_conv_nchwc_tile_selection_t sel =
+      iree_uk_conv_nchwc_select_tile_func_arch(params);
+
+  // If no target specific tile function is available, fall back to a generic
+  // one if allowed by the flags.
+  if (!sel.tile_func) {
+    if (params->flags &
+        IREE_UK_FLAG_CONV_NCHWC_ALLOW_GENERIC_FALLBACK_TILE_FUNCTION) {
+      sel = iree_uk_conv_nchwc_select_tile_func_generic(params);
+    } else {
+      IREE_UK_ASSERT(
+          0 && "no target-specific tile function, and fallback not enabled.");
+    }
+  }
+  iree_uk_conv_nchwc_using_tile_func(params, sel.tile_func, sel.ow_tile);
+}
+
+IREE_UK_EXPORT void iree_uk_conv_nchwc(
+    const void* input_buffer, iree_uk_index_t input_offset,
+    iree_uk_index_t input_stride_n, iree_uk_index_t input_stride_ic_outer,
+    iree_uk_index_t input_stride_h, const void* filter_buffer,
+    iree_uk_index_t filter_offset, iree_uk_index_t filter_stride_oc_outer,
+    iree_uk_index_t filter_stride_ic_outer, iree_uk_index_t filter_stride_fh,
+    void* output_buffer, iree_uk_index_t output_offset,
+    iree_uk_index_t output_stride_n, iree_uk_index_t output_stride_oc_outer,
+    iree_uk_index_t output_stride_oh, iree_uk_index_t N,
+    iree_uk_index_t OC_outer, iree_uk_index_t OH, iree_uk_index_t OW,
+    iree_uk_index_t IC_outer, iree_uk_index_t FH, iree_uk_index_t FW,
+    iree_uk_int32_t k0, iree_uk_int32_t c0, iree_uk_int32_t stride_h,
+    iree_uk_int32_t stride_w, iree_uk_uint32_t flags,
+    const iree_uk_uint64_t* cpu_data) {
+  iree_uk_conv_nchwc_params_t params = {
+      .input_buffer = input_buffer,
+      .input_offset = input_offset,
+      .input_stride_n = input_stride_n,
+      .input_stride_ic_outer = input_stride_ic_outer,
+      .input_stride_h = input_stride_h,
+      .filter_buffer = filter_buffer,
+      .filter_offset = filter_offset,
+      .filter_stride_oc_outer = filter_stride_oc_outer,
+      .filter_stride_ic_outer = filter_stride_ic_outer,
+      .filter_stride_fh = filter_stride_fh,
+      .output_buffer = output_buffer,
+      .output_offset = output_offset,
+      .output_stride_n = output_stride_n,
+      .output_stride_oc_outer = output_stride_oc_outer,
+      .output_stride_oh = output_stride_oh,
+      .N = N,
+      .OC_outer = OC_outer,
+      .OH = OH,
+      .OW = OW,
+      .IC_outer = IC_outer,
+      .FH = FH,
+      .FW = FW,
+      .k0 = k0,
+      .c0 = c0,
+      .stride_h = stride_h,
+      .stride_w = stride_w,
+      .flags = flags,
+      .cpu_data = cpu_data,
+  };
+  iree_uk_conv_nchwc_p(&params);
+}
+
+iree_uk_uint32_t iree_uk_conv_nchwc_info_p(
+    const iree_uk_conv_nchwc_params_t* params) {
+  iree_uk_uint32_t result = 0;
+  if (iree_uk_conv_nchwc_select_tile_func_arch(params).tile_func) {
+    result |=
+        IREE_UK_FLAG_CONV_NCHWC_INFO_HAVE_ARCHITECTURE_SPECIFIC_TILE_FUNCTION;
+  }
+  return result;
+}
+
+IREE_UK_EXPORT iree_uk_uint32_t iree_uk_conv_nchwc_info(
+    iree_uk_int32_t k0, iree_uk_int32_t c0, iree_uk_uint32_t flags,
+    const iree_uk_uint64_t* cpu_data) {
+  iree_uk_conv_nchwc_params_t params = {
+      .k0 = k0, .c0 = c0, .flags = flags, .cpu_data = cpu_data};
+  return iree_uk_conv_nchwc_info_p(&params);
+}
diff --git a/runtime/src/iree/builtins/ukernel/conv_nchwc.h b/runtime/src/iree/builtins/ukernel/conv_nchwc.h
new file mode 100644
index 0000000..b4d8ff2
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/conv_nchwc.h
@@ -0,0 +1,34 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef IREE_BUILTINS_UKERNEL_CONV_NCHWC_H_
+#define IREE_BUILTINS_UKERNEL_CONV_NCHWC_H_
+
+#include "iree/builtins/ukernel/common.h"
+
+// `conv_nchwc` microkernel. Computes a data-tiled NCHWc 2D convolution.
+IREE_UK_EXPORT void iree_uk_conv_nchwc(
+    const void* input_buffer, iree_uk_index_t input_offset,
+    iree_uk_index_t input_stride_n, iree_uk_index_t input_stride_ic_outer,
+    iree_uk_index_t input_stride_h, const void* filter_buffer,
+    iree_uk_index_t filter_offset, iree_uk_index_t filter_stride_oc_outer,
+    iree_uk_index_t filter_stride_ic_outer, iree_uk_index_t filter_stride_fh,
+    void* output_buffer, iree_uk_index_t output_offset,
+    iree_uk_index_t output_stride_n, iree_uk_index_t output_stride_oc_outer,
+    iree_uk_index_t output_stride_oh, iree_uk_index_t N,
+    iree_uk_index_t OC_outer, iree_uk_index_t OH, iree_uk_index_t OW,
+    iree_uk_index_t IC_outer, iree_uk_index_t FH, iree_uk_index_t FW,
+    iree_uk_int32_t k0, iree_uk_int32_t c0, iree_uk_int32_t stride_h,
+    iree_uk_int32_t stride_w, iree_uk_uint32_t flags,
+    const iree_uk_uint64_t* cpu_data);
+
+// Returns a bit-field of information about how a conv_nchwc with the given
+// tile shape and flags would run on the current target.
+IREE_UK_EXPORT iree_uk_uint32_t iree_uk_conv_nchwc_info(
+    iree_uk_int32_t k0, iree_uk_int32_t c0, iree_uk_uint32_t flags,
+    const iree_uk_uint64_t* cpu_data);
+
+#endif  // IREE_BUILTINS_UKERNEL_CONV_NCHWC_H_
diff --git a/runtime/src/iree/builtins/ukernel/conv_nchwc_internal.h b/runtime/src/iree/builtins/ukernel/conv_nchwc_internal.h
new file mode 100644
index 0000000..f12d3d4
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/conv_nchwc_internal.h
@@ -0,0 +1,127 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef IREE_BUILTINS_UKERNEL_CONV_NCHWC_INTERNAL_H_
+#define IREE_BUILTINS_UKERNEL_CONV_NCHWC_INTERNAL_H_
+
+#include "iree/builtins/ukernel/conv_nchwc.h"
+
+// Internal params struct passed between implementation functions.
+typedef struct iree_uk_conv_nchwc_params_t {
+  const void* input_buffer;
+  iree_uk_index_t input_offset;
+  iree_uk_index_t input_stride_n;
+  iree_uk_index_t input_stride_ic_outer;
+  iree_uk_index_t input_stride_h;
+  const void* filter_buffer;
+  iree_uk_index_t filter_offset;
+  iree_uk_index_t filter_stride_oc_outer;
+  iree_uk_index_t filter_stride_ic_outer;
+  iree_uk_index_t filter_stride_fh;
+  void* output_buffer;
+  iree_uk_index_t output_offset;
+  iree_uk_index_t output_stride_n;
+  iree_uk_index_t output_stride_oc_outer;
+  iree_uk_index_t output_stride_oh;
+  iree_uk_index_t N;
+  iree_uk_index_t OC_outer;
+  iree_uk_index_t OH;
+  iree_uk_index_t OW;
+  iree_uk_index_t IC_outer;
+  iree_uk_index_t FH;
+  iree_uk_index_t FW;
+  iree_uk_int32_t k0;  // inner OC block
+  iree_uk_int32_t c0;  // inner IC block
+  iree_uk_int32_t stride_h;
+  iree_uk_int32_t stride_w;
+  iree_uk_uint32_t flags;
+  // ow_count: how many output columns this particular tile call should produce.
+  // Equals the selected tile's ow_tile for full tiles; smaller only on the
+  // partial tail, as set by the driver.
+  iree_uk_index_t ow_count;
+  const iree_uk_uint64_t* cpu_data;
+} iree_uk_conv_nchwc_params_t;
+
+// Equivalent to the public iree_uk_conv_nchwc entry point but taking the params
+// struct.
+void iree_uk_conv_nchwc_p(const iree_uk_conv_nchwc_params_t* params);
+
+// Equivalent to the public iree_uk_conv_nchwc_info but operating on the params
+// struct. Only the struct fields corresponding to iree_uk_conv_nchwc_info
+// params are used.
+iree_uk_uint32_t iree_uk_conv_nchwc_info_p(
+    const iree_uk_conv_nchwc_params_t* params);
+
+// TODO: extend to match mmt4d's coverage (S8S8S32, S16/U4 variants, F16,
+// BF16) once those paths are exercised by the compiler-side encoding
+// resolver. For now this ukernel is F32-only.
+typedef enum iree_uk_conv_nchwc_type_t {
+  iree_uk_conv_nchwc_type_f32f32f32 =
+      IREE_UK_TIE_3_TYPES_LITERAL(FLOAT_32, FLOAT_32, FLOAT_32),
+} iree_uk_conv_nchwc_type_t;
+
+static inline iree_uk_conv_nchwc_type_t iree_uk_conv_nchwc_type(
+    iree_uk_uint32_t flags) {
+  switch (flags & IREE_UK_FLAG_CONV_NCHWC_TYPE_MASK) {
+    case IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32:
+      return iree_uk_conv_nchwc_type_f32f32f32;
+    default:
+      // Work around a LLVM/riscv32 miscompile. Without the unreachable here,
+      // returning (iree_uk_conv_nchwc_type_t)0 causes this whole switch
+      // statement to be miscompiled by LLVM/riscv32 as if it were UB, as the 0
+      // was passed to `iree_uk_type_bit_count(x)`, which evaluates to
+      // `1<<(x - 3)`, which is UB if x<3.
+#if defined(IREE_UK_COMPILER_CLANG) && defined(IREE_UK_ARCH_RISCV_32)
+      __builtin_unreachable();
+#endif
+      // Shouldn't happen, validated earlier.
+      return (iree_uk_conv_nchwc_type_t)0;
+  }
+}
+
+static inline iree_uk_type_t iree_uk_conv_nchwc_input_type(
+    iree_uk_conv_nchwc_type_t type) {
+  return iree_uk_untie_type(0, type);
+}
+
+static inline iree_uk_type_t iree_uk_conv_nchwc_filter_type(
+    iree_uk_conv_nchwc_type_t type) {
+  return iree_uk_untie_type(1, type);
+}
+
+static inline iree_uk_type_t iree_uk_conv_nchwc_output_type(
+    iree_uk_conv_nchwc_type_t type) {
+  return iree_uk_untie_type(2, type);
+}
+
+// Function pointer that computes one (ow_count x k0) output panel.
+// The tile function performs the full reduction over (ic_outer, fh, fw,
+// c_inner) internally. The driver calls it once per (n, oc_outer, oh, ow-tile)
+// output panel. When the reduction range is empty (IC_outer/FH/FW == 0), the
+// driver still calls the tile function in the non-accumulate case, and the tile
+// function must write zeros to the output panel.
+typedef void (*iree_uk_conv_nchwc_tile_func_t)(
+    void* IREE_UK_RESTRICT output_panel,
+    const void* IREE_UK_RESTRICT input_panel,
+    const void* IREE_UK_RESTRICT filter_panel,
+    const iree_uk_conv_nchwc_params_t* params);
+
+// A selected tile function plus the output-width tile size it was compiled
+// for.
+typedef struct iree_uk_conv_nchwc_tile_selection_t {
+  iree_uk_conv_nchwc_tile_func_t tile_func;  // null if unavailable
+  iree_uk_int32_t ow_tile;
+} iree_uk_conv_nchwc_tile_selection_t;
+
+// Architecture-specific implementation, or generic fallback returning null
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_arch(
+    const iree_uk_conv_nchwc_params_t* params);
+
+// Generic fallback.
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_generic(
+    const iree_uk_conv_nchwc_params_t* params);
+
+#endif  // IREE_BUILTINS_UKERNEL_CONV_NCHWC_INTERNAL_H_
diff --git a/runtime/src/iree/builtins/ukernel/conv_nchwc_tile_generic.c b/runtime/src/iree/builtins/ukernel/conv_nchwc_tile_generic.c
new file mode 100644
index 0000000..b6bf758
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/conv_nchwc_tile_generic.c
@@ -0,0 +1,77 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+#include "iree/builtins/ukernel/exported_bits.h"
+
+// Generic scalar implementation of the conv_nchwc tile function for f32.
+//
+// Computes one output panel of shape [ow_count, k0] by iterating over
+// (ic_outer, fh, fw, c_inner) and accumulating. Used as a reference kernel
+// for correctness checking and as a portable fallback when no ISA-specific
+// tile function is available.
+static void iree_uk_conv_nchwc_tile_f32f32f32_generic(
+    void* IREE_UK_RESTRICT output_panel,
+    const void* IREE_UK_RESTRICT input_panel,
+    const void* IREE_UK_RESTRICT filter_panel,
+    const iree_uk_conv_nchwc_params_t* params) {
+  float* out_ptr = (float*)output_panel;
+  const float* in_ptr = (const float*)input_panel;
+  const float* f_ptr = (const float*)filter_panel;
+
+  const iree_uk_index_t ow_count = params->ow_count;
+  const iree_uk_index_t IC_outer = params->IC_outer;
+  const iree_uk_index_t FH = params->FH;
+  const iree_uk_index_t FW = params->FW;
+  const iree_uk_int32_t k0 = params->k0;
+  const iree_uk_int32_t c0 = params->c0;
+  const iree_uk_int32_t stride_w = params->stride_w;
+
+  const iree_uk_index_t in_stride_w = c0;
+  const iree_uk_index_t in_stride_h = params->input_stride_h;
+  const iree_uk_index_t in_stride_ic_outer = params->input_stride_ic_outer;
+
+  const iree_uk_index_t f_stride_fw = (iree_uk_index_t)c0 * k0;
+  const iree_uk_index_t f_stride_fh = params->filter_stride_fh;
+  const iree_uk_index_t f_stride_ic_outer = params->filter_stride_ic_outer;
+
+  for (iree_uk_index_t i = 0; i < ow_count; ++i) {
+    for (iree_uk_int32_t ko = 0; ko < k0; ++ko) {
+      float acc = (params->flags & IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE)
+                      ? out_ptr[i * k0 + ko]
+                      : 0.f;
+      for (iree_uk_index_t ic_o = 0; ic_o < IC_outer; ++ic_o) {
+        for (iree_uk_index_t fh = 0; fh < FH; ++fh) {
+          for (iree_uk_index_t fw = 0; fw < FW; ++fw) {
+            for (iree_uk_int32_t ci = 0; ci < c0; ++ci) {
+              float in_val =
+                  in_ptr[ic_o * in_stride_ic_outer + fh * in_stride_h +
+                         (i * stride_w + fw) * in_stride_w + ci];
+              float f_val = f_ptr[ic_o * f_stride_ic_outer + fh * f_stride_fh +
+                                  fw * f_stride_fw + ci * k0 + ko];
+              acc += in_val * f_val;
+            }
+          }
+        }
+      }
+      out_ptr[i * k0 + ko] = acc;
+    }
+  }
+}
+
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_generic(
+    const iree_uk_conv_nchwc_params_t* params) {
+  iree_uk_conv_nchwc_tile_selection_t selection = {0};
+  switch (iree_uk_conv_nchwc_type(params->flags)) {
+    case iree_uk_conv_nchwc_type_f32f32f32:
+      selection.tile_func = iree_uk_conv_nchwc_tile_f32f32f32_generic;
+      selection.ow_tile = 16;
+      break;
+    default:
+      break;
+  }
+  return selection;
+}
diff --git a/runtime/src/iree/builtins/ukernel/exported_bits.h b/runtime/src/iree/builtins/ukernel/exported_bits.h
index aa25b0d..571ceef 100644
--- a/runtime/src/iree/builtins/ukernel/exported_bits.h
+++ b/runtime/src/iree/builtins/ukernel/exported_bits.h
@@ -105,4 +105,22 @@
 #define IREE_UK_FLAG_QUERY_TILE_SIZES_OPERATION_MATMUL_BF16BF16F32 0x0500
 #define IREE_UK_FLAG_QUERY_TILE_SIZES_OPERATION_MATMUL_BF16BF16BF16 0x0600
 
+//===----------------------------------------------------------------------===//
+// conv_nchwc
+//===----------------------------------------------------------------------===//
+
+// type enum (lhs = input, rhs = filter, out = output)
+#define IREE_UK_FLAG_CONV_NCHWC_TYPE_MASK 0xFF
+#define IREE_UK_FLAG_CONV_NCHWC_TYPE_NONE 0x00
+#define IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32 0x01
+#define IREE_UK_FLAG_CONV_NCHWC_TYPE_END 0x02
+
+// bit flags
+#define IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE 0x100
+#define IREE_UK_FLAG_CONV_NCHWC_ALLOW_GENERIC_FALLBACK_TILE_FUNCTION 0x200
+
+// info bit flags returned by iree_uk_conv_nchwc_info.
+#define IREE_UK_FLAG_CONV_NCHWC_INFO_HAVE_ARCHITECTURE_SPECIFIC_TILE_FUNCTION \
+  0x1
+
 #endif  // IREE_BUILTINS_UKERNEL_EXPORTED_BITS_H_
diff --git a/runtime/src/iree/builtins/ukernel/fallback.c b/runtime/src/iree/builtins/ukernel/fallback.c
index eaee5b2..779e7da 100644
--- a/runtime/src/iree/builtins/ukernel/fallback.c
+++ b/runtime/src/iree/builtins/ukernel/fallback.c
@@ -4,6 +4,7 @@
 // See https://llvm.org/LICENSE.txt for license information.
 // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
 
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
 #include "iree/builtins/ukernel/mmt4d_internal.h"
 #include "iree/builtins/ukernel/pack_internal.h"
 #include "iree/builtins/ukernel/query_tile_sizes_internal.h"
@@ -29,3 +30,9 @@
     iree_uk_matmul_tile_sizes_t* out_matmul_tile_sizes) {
   return false;
 }
+
+iree_uk_conv_nchwc_tile_selection_t iree_uk_conv_nchwc_select_tile_func_arch(
+    const iree_uk_conv_nchwc_params_t* params) {
+  iree_uk_conv_nchwc_tile_selection_t selection = {0};
+  return selection;
+}
diff --git a/runtime/src/iree/builtins/ukernel/tools/BUILD.bazel b/runtime/src/iree/builtins/ukernel/tools/BUILD.bazel
index 2f5d41c..d543288 100644
--- a/runtime/src/iree/builtins/ukernel/tools/BUILD.bazel
+++ b/runtime/src/iree/builtins/ukernel/tools/BUILD.bazel
@@ -104,6 +104,33 @@
     ],
 )
 
+iree_runtime_cc_test(
+    name = "conv_nchwc_test",
+    srcs = ["conv_nchwc_test.c"],
+    deps = [
+        ":test",
+        ":util",
+        "//runtime/src/iree/base",
+        "//runtime/src/iree/base/internal",
+        "//runtime/src/iree/builtins/ukernel",
+        "//runtime/src/iree/builtins/ukernel:internal_headers",
+    ],
+)
+
+cc_binary_benchmark(
+    name = "conv_nchwc_benchmark",
+    srcs = ["conv_nchwc_benchmark.c"],
+    deps = [
+        ":benchmark",
+        ":util",
+        "//runtime/src/iree/base",
+        "//runtime/src/iree/base/tooling:flags",
+        "//runtime/src/iree/builtins/ukernel",
+        "//runtime/src/iree/builtins/ukernel:internal_headers",
+        "//runtime/src/iree/testing:benchmark",
+    ],
+)
+
 cc_binary_benchmark(
     name = "pack_benchmark",
     srcs = ["pack_benchmark.c"],
diff --git a/runtime/src/iree/builtins/ukernel/tools/CMakeLists.txt b/runtime/src/iree/builtins/ukernel/tools/CMakeLists.txt
index 490d0b7..e9700a5 100644
--- a/runtime/src/iree/builtins/ukernel/tools/CMakeLists.txt
+++ b/runtime/src/iree/builtins/ukernel/tools/CMakeLists.txt
@@ -117,6 +117,36 @@
     iree::builtins::ukernel::internal_headers
 )
 
+iree_cc_test(
+  NAME
+    conv_nchwc_test
+  SRCS
+    "conv_nchwc_test.c"
+  DEPS
+    ::test
+    ::util
+    iree::base
+    iree::base::internal
+    iree::builtins::ukernel
+    iree::builtins::ukernel::internal_headers
+)
+
+iree_cc_binary_benchmark(
+  NAME
+    conv_nchwc_benchmark
+  SRCS
+    "conv_nchwc_benchmark.c"
+  DEPS
+    ::benchmark
+    ::util
+    iree::base
+    iree::base::tooling::flags
+    iree::builtins::ukernel
+    iree::builtins::ukernel::internal_headers
+    iree::testing::benchmark
+  TESTONLY
+)
+
 iree_cc_binary_benchmark(
   NAME
     pack_benchmark
diff --git a/runtime/src/iree/builtins/ukernel/tools/conv_nchwc_benchmark.c b/runtime/src/iree/builtins/ukernel/tools/conv_nchwc_benchmark.c
new file mode 100644
index 0000000..a36ab9e
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/tools/conv_nchwc_benchmark.c
@@ -0,0 +1,137 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include <stdio.h>
+
+#include "iree/base/api.h"
+#include "iree/base/tooling/flags.h"
+#include "iree/builtins/ukernel/api.h"
+#include "iree/builtins/ukernel/conv_nchwc.h"
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+#include "iree/builtins/ukernel/exported_bits.h"
+#include "iree/builtins/ukernel/tools/benchmark.h"
+#include "iree/builtins/ukernel/tools/util.h"
+
+IREE_FLAG(int32_t, n_size, 1, "Batch (N) of the conv.");
+IREE_FLAG(int32_t, oc_size, 8, "Output-channel tiles (OC / k0).");
+IREE_FLAG(int32_t, ic_size, 8, "Input-channel tiles (IC / c0).");
+IREE_FLAG(int32_t, oh_size, 28, "Output height (OH).");
+IREE_FLAG(int32_t, ow_size, 28, "Output width (OW).");
+IREE_FLAG(int32_t, fh_size, 3, "Filter height (FH).");
+IREE_FLAG(int32_t, fw_size, 3, "Filter width (FW).");
+IREE_FLAG(bool, accumulate, false,
+          "Whether the kernel should accumulate into the existing output, or "
+          "overwrite it.");
+
+static iree_status_t iree_uk_benchmark_conv_nchwc(
+    const iree_benchmark_def_t* benchmark_def,
+    iree_benchmark_state_t* benchmark_state) {
+  const iree_uk_benchmark_user_data_t* user_data = benchmark_def->user_data;
+  const iree_uk_conv_nchwc_params_t* src_params =
+      iree_uk_benchmark_params(user_data);
+  iree_uk_conv_nchwc_params_t params;
+  memcpy(&params, src_params, sizeof params);
+  params.cpu_data = iree_uk_benchmark_cpu_data(user_data);
+  if (FLAG_accumulate) params.flags |= IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE;
+  params.N = FLAG_n_size;
+  params.OC_outer = FLAG_oc_size;
+  params.IC_outer = FLAG_ic_size;
+  params.OH = FLAG_oh_size;
+  params.OW = FLAG_ow_size;
+  params.FH = FLAG_fh_size;
+  params.FW = FLAG_fw_size;
+
+  iree_uk_index_t H = (params.OH - 1) * params.stride_h + params.FH;
+  iree_uk_index_t W = (params.OW - 1) * params.stride_w + params.FW;
+  params.input_stride_h = W * params.c0;
+  params.input_stride_ic_outer = H * params.input_stride_h;
+  params.input_stride_n = params.IC_outer * params.input_stride_ic_outer;
+  iree_uk_index_t filter_stride_fw = (iree_uk_index_t)params.c0 * params.k0;
+  params.filter_stride_fh = params.FW * filter_stride_fw;
+  params.filter_stride_ic_outer = params.FH * params.filter_stride_fh;
+  params.filter_stride_oc_outer =
+      params.IC_outer * params.filter_stride_ic_outer;
+  params.output_stride_oh = params.OW * params.k0;
+  params.output_stride_oc_outer = params.OH * params.output_stride_oh;
+  params.output_stride_n = params.OC_outer * params.output_stride_oc_outer;
+
+  iree_uk_conv_nchwc_type_t type = iree_uk_conv_nchwc_type(params.flags);
+  iree_uk_type_t in_type = iree_uk_conv_nchwc_input_type(type);
+  iree_uk_type_t f_type = iree_uk_conv_nchwc_filter_type(type);
+  iree_uk_type_t out_type = iree_uk_conv_nchwc_output_type(type);
+  iree_uk_index_t in_size =
+      iree_uk_2d_buffer_length(in_type, params.N, params.input_stride_n);
+  iree_uk_index_t f_size = iree_uk_2d_buffer_length(
+      f_type, params.OC_outer, params.filter_stride_oc_outer);
+  iree_uk_index_t out_size =
+      iree_uk_2d_buffer_length(out_type, params.N, params.output_stride_n);
+  void* in_buffer = malloc(in_size);
+  void* f_buffer = malloc(f_size);
+  void* out_buffer = malloc(out_size);
+  iree_uk_random_engine_t* engine = iree_uk_benchmark_random_engine(user_data);
+  iree_uk_write_random_buffer(in_buffer, in_size, in_type, engine);
+  iree_uk_write_random_buffer(f_buffer, f_size, f_type, engine);
+  iree_uk_write_random_buffer(out_buffer, out_size, out_type, engine);
+  params.input_buffer = in_buffer;
+  params.filter_buffer = f_buffer;
+  params.output_buffer = out_buffer;
+
+  int64_t total_iterations = 0;
+  int64_t batch_count = 1;
+  while (iree_benchmark_keep_running(benchmark_state, batch_count)) {
+    for (int i = 0; i < batch_count; ++i) {
+      iree_uk_conv_nchwc_p(&params);
+    }
+    total_iterations += batch_count;
+    batch_count *= 2;
+  }
+  // FLOPs: 2 (multiply-add) per (output element x reduction element).
+  iree_benchmark_set_items_processed(
+      benchmark_state, total_iterations * 2 * params.N * params.OC_outer *
+                           params.k0 * params.OH * params.OW * params.IC_outer *
+                           params.c0 * params.FH * params.FW);
+  free(in_buffer);
+  free(f_buffer);
+  free(out_buffer);
+  return iree_ok_status();
+}
+
+static void iree_uk_benchmark_register_conv_nchwc(iree_uk_uint32_t flags,
+                                                  int k0, int c0,
+                                                  const char* cpu_features) {
+  char type_str[32];
+  iree_uk_conv_nchwc_type_t type = iree_uk_conv_nchwc_type(flags);
+  iree_uk_type_triple_str(type_str, sizeof type_str, type);
+  char name[128];
+  iree_snprintf(name, sizeof name, "conv_nchwc_%s_tile_%dx%d", type_str, k0,
+                c0);
+  iree_uk_conv_nchwc_params_t params = {
+      .flags =
+          flags | IREE_UK_FLAG_CONV_NCHWC_ALLOW_GENERIC_FALLBACK_TILE_FUNCTION,
+      .k0 = k0,
+      .c0 = c0,
+      .stride_h = 1,
+      .stride_w = 1};
+  iree_uk_benchmark_register(name, iree_uk_benchmark_conv_nchwc, &params,
+                             sizeof params, cpu_features);
+}
+
+int main(int argc, char** argv) {
+  iree_flags_set_usage("conv_nchwc_benchmark", "");
+
+  iree_flags_parse_checked(IREE_FLAGS_PARSE_MODE_UNDEFINED_OK, &argc, &argv);
+  iree_uk_benchmark_initialize(&argc, argv);
+
+#if defined(IREE_ARCH_X86_64)
+  iree_uk_benchmark_register_conv_nchwc(IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32,
+                                        16, 16, "avx512_base");
+#endif
+  // Generic fallback (no CPU features), for comparison against the arch tile.
+  iree_uk_benchmark_register_conv_nchwc(IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32,
+                                        16, 16, "");
+
+  iree_uk_benchmark_run_and_cleanup();
+}
diff --git a/runtime/src/iree/builtins/ukernel/tools/conv_nchwc_test.c b/runtime/src/iree/builtins/ukernel/tools/conv_nchwc_test.c
new file mode 100644
index 0000000..c812b6a
--- /dev/null
+++ b/runtime/src/iree/builtins/ukernel/tools/conv_nchwc_test.c
@@ -0,0 +1,312 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/base/api.h"
+#include "iree/builtins/ukernel/api.h"
+#include "iree/builtins/ukernel/conv_nchwc_internal.h"
+#include "iree/builtins/ukernel/exported_bits.h"
+#include "iree/builtins/ukernel/tools/test.h"
+#include "iree/builtins/ukernel/tools/util.h"
+
+// Inner reduction (ic_o, fh, fw, ci) for one output element. The outer driver
+// below computes `in_window` (pointer to input[n, 0, oh*sH, ow*sW, 0]) and
+// `f_slice` (pointer to filter[oc_o, 0, 0, 0, 0, ko]) and dispatches into here.
+static void iree_uk_conv_nchwc_reference_innerloop_f32f32f32(
+    float* out_ptr, const float* in_window, const float* f_slice,
+    const iree_uk_conv_nchwc_params_t* params) {
+  float acc =
+      params->flags & IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE ? *out_ptr : 0.f;
+  const iree_uk_index_t c0 = params->c0;
+  const iree_uk_index_t k0 = params->k0;
+  for (iree_uk_index_t ic_o = 0; ic_o < params->IC_outer; ++ic_o) {
+    const float* in_ic = in_window + ic_o * params->input_stride_ic_outer;
+    const float* f_ic = f_slice + ic_o * params->filter_stride_ic_outer;
+    for (iree_uk_index_t fh = 0; fh < params->FH; ++fh) {
+      const float* in_fh = in_ic + fh * params->input_stride_h;
+      const float* f_fh = f_ic + fh * params->filter_stride_fh;
+      for (iree_uk_index_t fw = 0; fw < params->FW; ++fw) {
+        const float* f_fw = f_fh + fw * (c0 * k0);
+        for (iree_uk_index_t ci = 0; ci < c0; ++ci) {
+          acc += in_fh[fw * c0 + ci] * f_fw[ci * k0];
+        }
+      }
+    }
+  }
+  *out_ptr = acc;
+}
+
+static void iree_uk_conv_nchwc_reference(
+    const iree_uk_conv_nchwc_params_t* params) {
+  iree_uk_conv_nchwc_type_t type = iree_uk_conv_nchwc_type(params->flags);
+  iree_uk_index_t in_elem_size =
+      iree_uk_type_size(iree_uk_conv_nchwc_input_type(type));
+  iree_uk_index_t f_elem_size =
+      iree_uk_type_size(iree_uk_conv_nchwc_filter_type(type));
+  iree_uk_index_t out_elem_size =
+      iree_uk_type_size(iree_uk_conv_nchwc_output_type(type));
+  const char* in_base =
+      (const char*)params->input_buffer + params->input_offset * in_elem_size;
+  const char* f_base =
+      (const char*)params->filter_buffer + params->filter_offset * f_elem_size;
+  char* out_base =
+      (char*)params->output_buffer + params->output_offset * out_elem_size;
+
+  for (iree_uk_index_t n = 0; n < params->N; ++n) {
+    for (iree_uk_index_t oc_o = 0; oc_o < params->OC_outer; ++oc_o) {
+      for (iree_uk_index_t oh = 0; oh < params->OH; ++oh) {
+        for (iree_uk_index_t ow = 0; ow < params->OW; ++ow) {
+          const char* in_window =
+              in_base + (n * params->input_stride_n +
+                         oh * params->stride_h * params->input_stride_h +
+                         ow * params->stride_w * params->c0) *
+                            in_elem_size;
+          char* out_pixel =
+              out_base + (n * params->output_stride_n +
+                          oc_o * params->output_stride_oc_outer +
+                          oh * params->output_stride_oh + ow * params->k0) *
+                             out_elem_size;
+          for (iree_uk_index_t ko = 0; ko < params->k0; ++ko) {
+            const char* f_slice =
+                f_base +
+                (oc_o * params->filter_stride_oc_outer + ko) * f_elem_size;
+            void* out_ptr = out_pixel + ko * out_elem_size;
+            switch (params->flags & IREE_UK_FLAG_CONV_NCHWC_TYPE_MASK) {
+              case IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32:
+                iree_uk_conv_nchwc_reference_innerloop_f32f32f32(
+                    (float*)out_ptr, (const float*)in_window,
+                    (const float*)f_slice, params);
+                break;
+              default:
+                IREE_UK_ASSERT(false && "unhandled type");
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+// Strides/Offsets are element counts. For a sub-byte type (e.g., i4), an
+// odd count is a fractional number of bytes which isn't byte addressable.
+// Round up to the next whole-byte count.
+static iree_uk_index_t iree_uk_test_round_up_to_ensure_multiple_of_8_bits(
+    iree_uk_index_t index, iree_uk_type_t type) {
+  while ((index << iree_uk_type_bit_count_log2(type)) & 7) ++index;
+  return index;
+}
+
+// Randomly make the strides either tight or not to exercise all cases.
+static iree_uk_index_t iree_uk_test_random_stride(
+    iree_uk_index_t min_stride, iree_uk_type_t type,
+    iree_uk_random_engine_t* engine) {
+  iree_uk_index_t stride = min_stride + iree_uk_random_engine_get_0_1(engine);
+  return iree_uk_test_round_up_to_ensure_multiple_of_8_bits(stride, type);
+}
+
+// Randomly make the offsets either tight or not to exercise all cases.
+static iree_uk_index_t iree_uk_test_random_offset(
+    iree_uk_type_t type, iree_uk_random_engine_t* engine) {
+  iree_uk_index_t offset = iree_uk_random_engine_get_0_1(engine);
+  return iree_uk_test_round_up_to_ensure_multiple_of_8_bits(offset, type);
+}
+
+static void iree_uk_test_conv_nchwc_for_shape_params(
+    iree_uk_test_t* test, const iree_uk_conv_nchwc_params_t* src_params) {
+  iree_uk_conv_nchwc_params_t params;
+  memcpy(&params, src_params, sizeof params);
+  iree_uk_conv_nchwc_type_t type = iree_uk_conv_nchwc_type(params.flags);
+  iree_uk_type_t in_type = iree_uk_conv_nchwc_input_type(type);
+  iree_uk_type_t f_type = iree_uk_conv_nchwc_filter_type(type);
+  iree_uk_type_t out_type = iree_uk_conv_nchwc_output_type(type);
+  iree_uk_random_engine_t* engine = iree_uk_test_random_engine(test);
+
+  // Input H/W are derived from output shape, filter shape, and stride.
+  iree_uk_index_t H = (params.OH - 1) * params.stride_h + params.FH;
+  iree_uk_index_t W = (params.OW - 1) * params.stride_w + params.FW;
+
+  // Populate the outer strides; innermost dims (W, OW, FW, c0, k0) stay
+  // pack-contiguous. We need these before sizing buffers below.
+  params.input_stride_h =
+      iree_uk_test_random_stride(W * params.c0, in_type, engine);
+  params.input_stride_ic_outer =
+      iree_uk_test_random_stride(H * params.input_stride_h, in_type, engine);
+  params.input_stride_n = iree_uk_test_random_stride(
+      params.IC_outer * params.input_stride_ic_outer, in_type, engine);
+
+  iree_uk_index_t filter_stride_fw = (iree_uk_index_t)params.c0 * params.k0;
+  params.filter_stride_fh =
+      iree_uk_test_random_stride(params.FW * filter_stride_fw, f_type, engine);
+  params.filter_stride_ic_outer = iree_uk_test_random_stride(
+      params.FH * params.filter_stride_fh, f_type, engine);
+  params.filter_stride_oc_outer = iree_uk_test_random_stride(
+      params.IC_outer * params.filter_stride_ic_outer, f_type, engine);
+
+  params.output_stride_oh =
+      iree_uk_test_random_stride(params.OW * params.k0, out_type, engine);
+  params.output_stride_oc_outer = iree_uk_test_random_stride(
+      params.OH * params.output_stride_oh, out_type, engine);
+  params.output_stride_n = iree_uk_test_random_stride(
+      params.OC_outer * params.output_stride_oc_outer, out_type, engine);
+
+  iree_uk_index_t in_buffer_size =
+      iree_uk_2d_buffer_length(in_type, params.N, params.input_stride_n);
+  iree_uk_index_t f_buffer_size = iree_uk_2d_buffer_length(
+      f_type, params.OC_outer, params.filter_stride_oc_outer);
+  iree_uk_index_t out_buffer_size =
+      iree_uk_2d_buffer_length(out_type, params.N, params.output_stride_n);
+  void* in_buffer = malloc(in_buffer_size);
+  void* f_buffer = malloc(f_buffer_size);
+  iree_uk_write_random_buffer(in_buffer, in_buffer_size, in_type, engine);
+  iree_uk_write_random_buffer(f_buffer, f_buffer_size, f_type, engine);
+
+  // Random offsets + pointer-shift trick: shift `buffer` back by `offset`, so
+  // the kernel's `buffer + offset` lands at the actual allocation. Exercises
+  // the kernel's offset arithmetic without inflating the allocation.
+  params.input_offset = iree_uk_test_random_offset(in_type, engine);
+  params.filter_offset = iree_uk_test_random_offset(f_type, engine);
+  params.output_offset = iree_uk_test_random_offset(out_type, engine);
+  params.input_buffer =
+      (const char*)in_buffer -
+      iree_uk_bits_to_bytes_exact(params.input_offset
+                                  << iree_uk_type_bit_count_log2(in_type));
+  params.filter_buffer =
+      (const char*)f_buffer -
+      iree_uk_bits_to_bytes_exact(params.filter_offset
+                                  << iree_uk_type_bit_count_log2(f_type));
+
+  void* init_out_buffer = malloc(out_buffer_size);
+  iree_uk_write_random_buffer(init_out_buffer, out_buffer_size, out_type,
+                              engine);
+  void* reference_out_buffer = malloc(out_buffer_size);
+  void* actual_out_buffer = malloc(out_buffer_size);
+  memcpy(reference_out_buffer, init_out_buffer, out_buffer_size);
+  memcpy(actual_out_buffer, init_out_buffer, out_buffer_size);
+
+  iree_uk_conv_nchwc_params_t reference_params;
+  memcpy(&reference_params, &params, sizeof params);
+  reference_params.output_buffer =
+      (char*)reference_out_buffer -
+      iree_uk_bits_to_bytes_exact(params.output_offset
+                                  << iree_uk_type_bit_count_log2(out_type));
+
+  iree_uk_conv_nchwc_params_t actual_params;
+  memcpy(&actual_params, &params, sizeof params);
+  actual_params.output_buffer =
+      (char*)actual_out_buffer -
+      iree_uk_bits_to_bytes_exact(params.output_offset
+                                  << iree_uk_type_bit_count_log2(out_type));
+
+  iree_uk_conv_nchwc_reference(&reference_params);
+  iree_uk_conv_nchwc_p(&actual_params);
+
+  // Exact compare: small-integer random fills keep every intermediate FMA
+  // exactly representable, so reduction order is immaterial.
+  if (memcmp(actual_out_buffer, reference_out_buffer, out_buffer_size)) {
+    IREE_UK_TEST_FAIL(test);
+  }
+
+  free(in_buffer);
+  free(f_buffer);
+  free(init_out_buffer);
+  free(reference_out_buffer);
+  free(actual_out_buffer);
+}
+
+static void iree_uk_test_conv_nchwc_for_tile_params(iree_uk_test_t* test,
+                                                    const void* src_params) {
+  typedef struct conv_shape_t {
+    int N, OC_outer, OH, OW, IC_outer, FH, FW, sH, sW;
+  } conv_shape_t;
+  const conv_shape_t shapes[] = {
+      // An output extent is 0, so exit early.
+      {0, 1, 3, 3, 1, 3, 3, 1, 1},  // N = 0
+      {1, 0, 3, 3, 1, 3, 3, 1, 1},  // OC_outer = 0
+      {1, 1, 0, 3, 1, 3, 3, 1, 1},  // OH = 0
+      {1, 1, 3, 0, 1, 3, 3, 1, 1},  // OW = 0
+      // Zero reduction: non-accumulate must zero the output panel; accumulate
+      // must leave it intact.
+      {1, 1, 2, 2, 0, 3, 3, 1, 1},  // IC_outer = 0
+      {1, 1, 2, 2, 1, 0, 3, 1, 1},  // FH = 0
+      {1, 1, 2, 2, 1, 3, 0, 1, 1},  // FW = 0
+      // Regular cases.
+      {1, 1, 1, 1, 1, 1, 1, 1, 1},
+      {1, 1, 1, 15, 1, 1, 1, 1, 1},
+      {1, 1, 1, 16, 1, 1, 1, 1, 1},
+      {1, 1, 1, 17, 1, 1, 1, 1, 1},
+      {1, 2, 3, 5, 2, 3, 3, 1, 1},
+      {2, 2, 5, 5, 2, 3, 3, 1, 1},
+      {1, 1, 3, 3, 1, 1, 1, 1, 1},
+      {1, 1, 5, 5, 1, 3, 3, 2, 2},
+      {1, 1, 1, 33, 1, 3, 3, 1, 1},
+  };
+  for (int i = 0; i < (int)IREE_ARRAYSIZE(shapes); ++i) {
+    iree_uk_conv_nchwc_params_t params;
+    memcpy(&params, src_params, sizeof params);
+    params.cpu_data = iree_uk_test_cpu_data(test);
+    if (!(params.flags &
+          IREE_UK_FLAG_CONV_NCHWC_ALLOW_GENERIC_FALLBACK_TILE_FUNCTION)) {
+      if (!(iree_uk_conv_nchwc_info_p(&params) &
+            IREE_UK_FLAG_CONV_NCHWC_INFO_HAVE_ARCHITECTURE_SPECIFIC_TILE_FUNCTION)) {
+        IREE_UK_ASSERT(0 &&
+                       "No architecture-specific tile function available "
+                       "for this case or missing CPU features, which should "
+                       "have been handled earlier.");
+        IREE_UK_TEST_FAIL(test);
+      }
+    }
+    conv_shape_t s = shapes[i];
+    params.N = s.N;
+    params.OC_outer = s.OC_outer;
+    params.OH = s.OH;
+    params.OW = s.OW;
+    params.IC_outer = s.IC_outer;
+    params.FH = s.FH;
+    params.FW = s.FW;
+    params.stride_h = s.sH;
+    params.stride_w = s.sW;
+    const iree_uk_uint32_t base_flags = params.flags;
+    for (int accumulate = 0; accumulate <= 1; ++accumulate) {
+      params.flags = base_flags;
+      if (accumulate) params.flags |= IREE_UK_FLAG_CONV_NCHWC_ACCUMULATE;
+      iree_uk_test_conv_nchwc_for_shape_params(test, &params);
+    }
+  }
+}
+
+static void iree_uk_test_conv_nchwc(iree_uk_uint32_t flags, int k0, int c0,
+                                    const char* cpu_features) {
+  // Always allow the fallback in this test. The problem with trying to enforce
+  // that no fallback is accidentally used, is that it's not easy to tell when
+  // a fallback is legitimate. It depends on the build system used (CMake or
+  // Bazel) and within the CMake case, it depends on the native toolchain. The
+  // ground truth is given by the IREE_UK_BUILD_* defines, but they are
+  // currently an implementation detail within each arch/ subdirectory.
+  flags |= IREE_UK_FLAG_CONV_NCHWC_ALLOW_GENERIC_FALLBACK_TILE_FUNCTION;
+  char types_str[32];
+  iree_uk_conv_nchwc_type_t type = iree_uk_conv_nchwc_type(flags);
+  iree_uk_type_triple_str(types_str, sizeof types_str, type);
+  iree_uk_conv_nchwc_params_t params = {.flags = flags, .k0 = k0, .c0 = c0};
+  char test_label_str[256];
+  snprintf(test_label_str, sizeof test_label_str, "types:%s tile:%dx%d",
+           types_str, k0, c0);
+  iree_uk_test(test_label_str, iree_uk_test_conv_nchwc_for_tile_params, &params,
+               cpu_features);
+}
+
+int main(int argc, char** argv) {
+  // Generic (no CPU features). Exercises the generic fallback tile.
+  iree_uk_test_conv_nchwc(IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32, 16, 16, "");
+  // A tile shape no arch dispatch table matches — exercises the generic
+  // fallback even when arch-specific tiles are compiled in.
+  iree_uk_test_conv_nchwc(IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32, 4, 4, "");
+
+#if defined(IREE_ARCH_X86_64)
+  iree_uk_test_conv_nchwc(IREE_UK_FLAG_CONV_NCHWC_TYPE_F32F32F32, 16, 16,
+                          "avx512_base");
+#endif
+
+  return iree_uk_test_exit_status();
+}