[Codegen][CPU] End-to-end-through-codegen lit test for ukernel framework. (#24571)

Closes the loop on the new C-bitcode ukernel framework with an
`iree-opt` lit test that drives the full LLVMCPU codegen pipeline on an
`inner_tiled` rooted dispatch wrapped in the standard
`hal.executable.variant` + binding-subspan structure, and asserts the
whole chain end-to-end:

- Bitcode reaches the dispatch's `hal.executable.variant` as a
first-class `objects(...)` attribute (the long-lived home).
- `llvm.func @iree_uk_mma_…` is declared with `hal.import.bitcode =
true`, the flag that tells `RewriteExternCallOpToDynamicImportCallOp` to
skip the runtime-import-table indirection.
- A direct `llvm.call @iree_uk_mma_…` replaces the original
`inner_tiled` — no `__import_ordinal_iree_uk_mma_…` (which would mean we
accidentally re-used the legacy runtime-resolved path), no leftover
`inner_tiled`, no leftover `ukernel.generic`.

To make that chain work, two pieces had to land alongside the test:

* `UKernelProviderAttr::createAndReplaceWithUkernelOp` now actually
builds the `UKernelGenericOp` (returning `success`, not `nullopt`),
setting `fn_def_attrs = {hal.import.bitcode = true}` on it. That flag
propagates onto the `func.func` declaration that
`LowerUKernelOpsToCalls` synthesizes, and through it onto the
`llvm.func` that `ConvertToLLVM` produces. Without this, the
"self-contained bitcode" property of the new framework would not hold —
every external call would be routed through the runtime import table
just like the legacy mmt4d ukernels.

* Bitcode lookup-and-attach is factored into a public helper
`IREE::CPU::attachUKernelBitcodeOnOp`, called both from
`LLVMCPUSelectUKernels` (at kernel-config time, mirroring GPU's
`ensureUKernelBitcodeAndFinalizeConfig`) and from the provider itself
(as a backstop for the BYO / hand-written-IR paths). It attaches the
bitcode both as a `hal.executable.objects` discardable attribute on the
op (the GPU-aligned shape) *and* directly on the enclosing
`hal.executable.variant`'s `objects` operand attribute (the durable
home, sidestepping passes that strip discardable attrs through
`ConvertToLLVM`).

Progress towards https://github.com/iree-org/iree/issues/24574.

Signed-off-by: Benoit Jacob <jacob.benoit.1@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel
index 6f5095f..a710d6a 100644
--- a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel
+++ b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel
@@ -17,6 +17,7 @@
     srcs = enforce_glob(
         # keep sorted
         [
+            "e2e_inner_tiled_pipeline.mlir",
             "lookup_builtin_and_byo.mlir",
             "lower_inner_tiled_to_bitcode_ukernel.mlir",
             "lower_inner_tiled_to_bitcode_ukernel_i8_vnni.mlir",
diff --git a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt
index 409e1e1..63f4c15 100644
--- a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt
+++ b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt
@@ -14,6 +14,7 @@
   NAME
     lit
   SRCS
+    "e2e_inner_tiled_pipeline.mlir"
     "lookup_builtin_and_byo.mlir"
     "lower_inner_tiled_to_bitcode_ukernel.mlir"
     "lower_inner_tiled_to_bitcode_ukernel_i8_vnni.mlir"
diff --git a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/e2e_inner_tiled_pipeline.mlir b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/e2e_inner_tiled_pipeline.mlir
new file mode 100644
index 0000000..9944696
--- /dev/null
+++ b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/e2e_inner_tiled_pipeline.mlir
@@ -0,0 +1,114 @@
+// RUN: iree-opt --split-input-file \
+// RUN:   --pass-pipeline="builtin.module(hal.executable(hal.executable.variant(iree-codegen-configuration-preprocessing-pipeline, builtin.module(iree-codegen-llvmcpu-configuration-pipeline, iree-codegen-llvmcpu-lowering-pipeline), iree-codegen-translation-postprocessing-pipeline)))" %s \
+// RUN: | FileCheck %s
+
+// End-to-end-through-codegen test for the new C-bitcode ukernel framework.
+// Drives the LLVMCPU configuration + lowering pipelines on an
+// `iree_codegen.inner_tiled` rooted dispatch wrapped in the standard
+// `hal.executable.variant` + binding-subspan structure (the same wrapper
+// shape `iree-compile` produces after dispatch creation). Asserts the
+// whole chain:
+//
+//   - SelectUKernels saw the `llvm_ukernels = "inner_tiled"` flag in the
+//     target config and the matching MMA intrinsic, so it annotated the
+//     `inner_tiled` and attached the matching bitcode to the parent
+//     `hal.executable.variant`'s `objects(...)` (the long-lived home; not
+//     a discardable attr that intermediate passes might strip).
+//   - LowerBitcodeUKernels rewrote the `inner_tiled` into a
+//     `ukernel.generic` with `fn_def_attrs = {hal.import.bitcode = true}`.
+//   - LowerUKernelOpsToCalls turned that into a `func.call` against a
+//     `func.func` declaration carrying `hal.import.bitcode`.
+//   - Final LLVM lowering emits `llvm.func @iree_uk_mma_…` with
+//     `hal.import.bitcode = true` and a direct `llvm.call` to it. The
+//     `hal.import.bitcode` flag is what makes
+//     `RewriteExternCallOpToDynamicImportCallOp` in `ConvertToLLVM` skip
+//     the runtime-import-table indirection it applies to every other
+//     external call — letting our call resolve directly against the
+//     linked bitcode at LLVM optimization time.
+
+#executable_target = #hal.executable.target<"llvm-cpu", "embedded-elf-x86_64", {
+  cpu_features = "+avx512f,+avx512bf16",
+  data_layout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128",
+  iree_codegen.ukernel_provider = #iree_cpu.ukernel_provider,
+  llvm_ukernels = "inner_tiled",
+  native_vector_size = 64 : index,
+  target_triple = "x86_64-unknown-unknown-eabi-elf"
+}>
+
+#pipeline_layout = #hal.pipeline.layout<bindings = [
+  #hal.pipeline.binding<storage_buffer, ReadOnly>,
+  #hal.pipeline.binding<storage_buffer, ReadOnly>,
+  #hal.pipeline.binding<storage_buffer>
+]>
+
+hal.executable private @bf16_inner_tiled_ukernel {
+  hal.executable.variant public @variant target(#executable_target) {
+    hal.executable.export public @bf16_inner_tiled_ukernel ordinal(0) layout(#pipeline_layout)
+        count(%device: !hal.device) -> (index, index, index) {
+      %x, %y, %z = iree_tensor_ext.dispatch.workgroup_count_from_slice()
+      hal.return %x, %y, %z : index, index, index
+    }
+    builtin.module {
+      func.func @bf16_inner_tiled_ukernel() {
+        %c0 = arith.constant 0 : index
+        %lhs = hal.interface.binding.subspan layout(#pipeline_layout) binding(0)
+            alignment(64) offset(%c0) flags(ReadOnly)
+            : !iree_tensor_ext.dispatch.tensor<readonly:tensor<2x4x1x2xbf16>>
+        %rhs = hal.interface.binding.subspan layout(#pipeline_layout) binding(1)
+            alignment(64) offset(%c0) flags(ReadOnly)
+            : !iree_tensor_ext.dispatch.tensor<readonly:tensor<2x4x16x2xbf16>>
+        %out = hal.interface.binding.subspan layout(#pipeline_layout) binding(2)
+            alignment(64) offset(%c0)
+            : !iree_tensor_ext.dispatch.tensor<readwrite:tensor<2x2x1x16xf32>>
+        %lhs_t = iree_tensor_ext.dispatch.tensor.load %lhs,
+            offsets = [0, 0, 0, 0], sizes = [2, 4, 1, 2], strides = [1, 1, 1, 1]
+            : !iree_tensor_ext.dispatch.tensor<readonly:tensor<2x4x1x2xbf16>> -> tensor<2x4x1x2xbf16>
+        %rhs_t = iree_tensor_ext.dispatch.tensor.load %rhs,
+            offsets = [0, 0, 0, 0], sizes = [2, 4, 16, 2], strides = [1, 1, 1, 1]
+            : !iree_tensor_ext.dispatch.tensor<readonly:tensor<2x4x16x2xbf16>> -> tensor<2x4x16x2xbf16>
+        %acc_t = iree_tensor_ext.dispatch.tensor.load %out,
+            offsets = [0, 0, 0, 0], sizes = [2, 2, 1, 16], strides = [1, 1, 1, 1]
+            : !iree_tensor_ext.dispatch.tensor<readwrite:tensor<2x2x1x16xf32>> -> tensor<2x2x1x16xf32>
+        %res = iree_codegen.inner_tiled ins(%lhs_t, %rhs_t) outs(%acc_t) {
+          indexing_maps = [
+            affine_map<(d0, d1, d2) -> (d0, d2)>,
+            affine_map<(d0, d1, d2) -> (d1, d2)>,
+            affine_map<(d0, d1, d2) -> (d0, d1)>
+          ],
+          iterator_types = [#linalg.iterator_type<parallel>,
+                            #linalg.iterator_type<parallel>,
+                            #linalg.iterator_type<reduction>],
+          kind = #iree_cpu.data_tiled_mma_layout<intrinsic = MMA_X86_AVX512BF16_1x16x2_F32_BF16>,
+          semantics = #iree_cpu.mma_semantics<>
+        } : tensor<2x4x1x2xbf16>, tensor<2x4x16x2xbf16> into tensor<2x2x1x16xf32>
+        iree_tensor_ext.dispatch.tensor.store %res, %out,
+            offsets = [0, 0, 0, 0], sizes = [2, 2, 1, 16], strides = [1, 1, 1, 1]
+            : tensor<2x2x1x16xf32> -> !iree_tensor_ext.dispatch.tensor<readwrite:tensor<2x2x1x16xf32>>
+        return
+      }
+    }
+  }
+}
+
+// CHECK-LABEL: hal.executable private @bf16_inner_tiled_ukernel
+//
+// The bitcode for the matched ukernel is attached as `objects(...)` on
+// the variant — the self-contained-IR property of the new framework.
+// CHECK:         hal.executable.variant public @variant
+// CHECK-SAME:      objects([
+// CHECK-SAME:        path = "iree_uk_mma_x86_avx512bf16_1x16x2_f32_bf16.x86_64_avx512bf16.bc"
+//
+// The ukernel function is declared as `llvm.func` carrying
+// `hal.import.bitcode = true` — the flag that tells `ConvertToLLVM` to
+// emit a direct call instead of routing through the runtime import table.
+// CHECK:         llvm.func @iree_uk_mma_x86_avx512bf16_1x16x2_f32_bf16
+// CHECK-SAME:      attributes {hal.import.bitcode = true
+//
+// And the dispatch function emits a direct `llvm.call` to that symbol.
+// No `inner_tiled`, no `ukernel.generic`, and no `__import_ordinal_`
+// indirection (which would mean we accidentally re-used the legacy
+// runtime-resolved-import path).
+// CHECK:         llvm.call @iree_uk_mma_x86_avx512bf16_1x16x2_f32_bf16
+// CHECK-NOT:     iree_codegen.inner_tiled
+// CHECK-NOT:     iree_codegen.ukernel.generic
+// CHECK-NOT:     __import_ordinal_iree_uk_mma
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUAttrs.cpp b/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUAttrs.cpp
index e1c867b..2eb1f43 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUAttrs.cpp
+++ b/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUAttrs.cpp
@@ -18,6 +18,8 @@
 #include "mlir/Dialect/LLVMIR/LLVMDialect.h"
 #include "mlir/Dialect/Linalg/IR/Linalg.h"
 #include "mlir/Dialect/Linalg/IR/LinalgInterfaces.h"
+#include "mlir/Dialect/Tensor/IR/Tensor.h"
+#include "mlir/Dialect/Utils/IndexingUtils.h"
 #include "mlir/Dialect/Vector/IR/VectorOps.h"
 #include "mlir/IR/AsmState.h"
 #include "mlir/IR/Attributes.h"
@@ -1192,51 +1194,204 @@
       cast<IREE::Util::SerializableAttrInterface>(bitcodeDenseAttr));
 }
 
+// Idempotently appends `bitcodeObject` to the `hal.executable.objects`
+// array on `op`. Returns true if the array already contained an
+// equivalent entry (i.e. nothing was changed).
+static bool addBitcodeObjectIfMissing(Operation *op, StringAttr attrId,
+                                      Attribute bitcodeObject) {
+  ArrayAttr existing = op->getAttrOfType<ArrayAttr>(attrId);
+  if (existing) {
+    for (Attribute a : existing) {
+      if (a == bitcodeObject) {
+        return true;
+      }
+    }
+  }
+  SmallVector<Attribute> objects;
+  if (existing) {
+    objects.append(existing.begin(), existing.end());
+  }
+  objects.push_back(bitcodeObject);
+  op->setAttr(attrId, ArrayAttr::get(op->getContext(), objects));
+  return false;
+}
+
+bool attachUKernelBitcodeOnOp(Operation *op, StringRef name) {
+  MLIRContext *context = op->getContext();
+  ArrayAttr sourceExecutableObjects = lookUpExecutableObjects(op);
+  IREE::HAL::ExecutableObjectAttr bitcodeObject =
+      getUKernelBitcode(context, sourceExecutableObjects, name);
+  if (!bitcodeObject) {
+    return false;
+  }
+  auto attrId = StringAttr::get(context, kHalExecutableObjectsAttrName);
+  addBitcodeObjectIfMissing(op, attrId, bitcodeObject);
+
+  // Also attach to the enclosing `hal.executable.variant`'s `objects` (a
+  // first-class operand attribute, not a discardable attr). This is the
+  // long-lived home for the bitcode: it survives codegen passes that
+  // would otherwise strip discardable attributes off intermediate ops,
+  // and matches the destination that `iree-hal-hoist-executable-objects`
+  // would eventually hoist to. Doing it eagerly here just shortens the
+  // distance the bitcode has to travel.
+  Operation *parent = op->getParentOp();
+  while (parent) {
+    if (auto variantOp = dyn_cast<IREE::HAL::ExecutableVariantOp>(parent)) {
+      SmallVector<Attribute> variantObjects;
+      if (std::optional<ArrayAttr> existing = variantOp.getObjects()) {
+        for (Attribute a : *existing) {
+          if (a == bitcodeObject) {
+            return true;
+          }
+          variantObjects.push_back(a);
+        }
+      }
+      variantObjects.push_back(bitcodeObject);
+      variantOp.setObjectsAttr(ArrayAttr::get(context, variantObjects));
+      return true;
+    }
+    parent = parent->getParentOp();
+  }
+  return true;
+}
+
+// Returns the index, in the ACC operand's shape, of the innermost
+// CrossIntrinsic dimension (the N cross-intrinsic dim for our layouts), or
+// nullopt if it is dynamic / absent. The ukernel needs this dim's stride to
+// address each unrolled intrinsic's ACC fragment.
+//
+// Example: with `MMA_X86_AVX512BF16_1x16x2` (each intrinsic produces a 1x16
+// f32 fragment) and intrinsics_m = intrinsics_n = 2, the ACC tile holds a 2x2
+// grid of such fragments. The two CrossIntrinsic dims are that grid's M and N;
+// the innermost is N, so this returns the index of the N-grid dim in the ACC
+// result shape, whose stride is the element distance from fragment (m, n) to
+// (m, n+1).
+static std::optional<unsigned>
+getAccInnermostCrossIntrinsicDim(IREE::Codegen::InnerTiledOp op,
+                                 DataTiledMMAAttr mma) {
+  auto outputType = dyn_cast<ShapedType>(op.getResultTypes()[0]);
+  if (!outputType) {
+    return std::nullopt;
+  }
+  Codegen::TileSwizzle accSwizzle = getSwizzle(mma, /*operandIdx=*/2);
+  SmallVector<Codegen::TileSwizzle::Dim> swizzleDims;
+  for (const Codegen::TileSwizzle::ExpandShapeDimVectorType &group :
+       accSwizzle.expandShape()) {
+    swizzleDims.append(group.begin(), group.end());
+  }
+  applyPermutationToVector(swizzleDims, accSwizzle.permutation());
+  int rankDiff = outputType.getRank() - static_cast<int>(swizzleDims.size());
+  auto crossIntrinsic = Codegen::TileSwizzle::Dim::Kind::CrossIntrinsic;
+  for (size_t i = swizzleDims.size(); i-- > 0;) {
+    if (swizzleDims[i].kind() != crossIntrinsic) {
+      continue;
+    }
+    int outputIdx = i + rankDiff;
+    if (outputType.isDynamicDim(outputIdx)) {
+      return std::nullopt;
+    }
+    return outputIdx;
+  }
+  // No CrossIntrinsic dims (intrinsics_m == intrinsics_n == 1): the single
+  // fragment sits at the start of the inner tile.
+  if (!swizzleDims.empty()) {
+    return rankDiff;
+  }
+  return std::nullopt;
+}
+
+// Rewrites an `inner_tiled` carrying a CPU `DataTiledMMAAttr` to a
+// `ukernel.generic`, threading the data-tiled-MMA scalar parameters as
+// operands so the ukernel can loop over arbitrary `intrinsics_{m,n,k}`:
+//   ins(lhs, rhs) outs(acc) (k_outer, intrinsics_m, intrinsics_n, intrinsics_k)
+// plus a `strided_dims` entry giving the ACC's innermost cross-intrinsic
+// stride.
+static LogicalResult
+handleInnerTiledMmaUkernel(RewriterBase &rewriter, StringRef name,
+                           IREE::Codegen::InnerTiledOp op, DataTiledMMAAttr mma,
+                           ArrayRef<Value> inputs, ArrayRef<Value> outputs,
+                           DictionaryAttr fnDefAttrs) {
+  std::optional<unsigned> accInnerDim =
+      getAccInnermostCrossIntrinsicDim(op, mma);
+  if (!accInnerDim) {
+    return rewriter.notifyMatchFailure(
+        op, "ACC innermost cross-intrinsic dim is dynamic or absent");
+  }
+  Location loc = op.getLoc();
+  Type i32 = rewriter.getI32Type();
+  auto constI32 = [&](int64_t v) {
+    return arith::ConstantIntOp::create(rewriter, loc, i32, v);
+  };
+  // Outer-K tile count: the LHS operand's outer dims are (m_outer, k_outer),
+  // so dim 1 is the K-tile count the ukernel loops over.
+  Value kOuter = arith::IndexCastOp::create(
+      rewriter, loc, i32,
+      tensor::DimOp::create(rewriter, loc, op.getInputs()[0], 1));
+  // `strided_dims` is the `ukernel.generic` ABI knob for which dims' strides
+  // are passed to the C function: a list per shaped operand (here LHS, RHS,
+  // ACC), each naming the dims whose stride follows that operand's
+  // `(base, offset)` in the call. An empty list passes `base, offset` only; a
+  // null attribute (the default, used by simpler ukernels) passes all strides.
+  // Here only ACC needs one — the innermost cross-intrinsic (N) dim — so the
+  // ukernel can address each unrolled intrinsic's ACC fragment. LHS/RHS are
+  // contiguous in the data-tiled layout, so they take no stride argument.
+  SmallVector<SmallVector<int64_t>> stridedDims(3, {});
+  stridedDims[2].push_back(*accInnerDim);
+  DictionaryAttr discardableAttrs = op->getDiscardableAttrDictionary();
+  auto newOp = rewriter.replaceOpWithNewOp<IREE::Codegen::UKernelGenericOp>(
+      op, op.getOutputs().getTypes(), name, inputs, outputs,
+      ValueRange{kOuter, constI32(mma.getIntrinsicsM()),
+                 constI32(mma.getIntrinsicsN()),
+                 constI32(mma.getIntrinsicsK())},
+      fnDefAttrs, stridedDims);
+  newOp->setDiscardableAttrs(discardableAttrs);
+  return success();
+}
+
 std::optional<LogicalResult> UKernelProviderAttr::createAndReplaceWithUkernelOp(
     RewriterBase &rewriter, StringRef name, DictionaryAttr targetConfiguration,
     Operation *contextualOp, ArrayRef<Value> inputs, ArrayRef<Value> outputs,
     SmallVectorImpl<Value> &otherOperands) const {
-  // Resolve the bitcode for this ukernel: user-supplied
-  // `hal.executable.objects` first (BYO and BYO-override paths), falling
-  // back to the built-in bitcode embedded into `iree-compile` at LLVMCPU
-  // plugin init. If found, attach it as `hal.executable.objects` on the
-  // source op so the default `LowerBitcodeUKernelsPass` rewrite preserves
-  // it on the resulting `ukernel.generic`. If the source op already
-  // carries a matching object, this is a no-op (the BYO case).
-  //
-  // We return `std::nullopt` to let the pass run its default
-  // `UKernelGenericOp` construction. Specialized `inner_tiled` handling
-  // (threading `intrinsics_{m,n,k}` and the outer K count as scalar
-  // operands, and computing the ACC inner stride) lands in a follow-up
-  // alongside SelectUKernels.
+  // Idempotent: in the normal flow `LLVMCPUSelectUKernels` attached the
+  // bitcode at kernel-config time, so this is a no-op. Still defensive
+  // for tests / future paths that bypass SelectUKernels.
+  rewriter.modifyOpInPlace(
+      contextualOp, [&] { attachUKernelBitcodeOnOp(contextualOp, name); });
+
+  // We build the `ukernel.generic` ourselves (rather than returning nullopt
+  // and letting the default fallback in `LowerBitcodeUKernelsPass` handle it)
+  // for two reasons: (1) to set `fn_def_attrs = {hal.import.bitcode = true}`,
+  // and (2) for `inner_tiled` ops, to thread the `DataTiledMMAAttr` scalar
+  // parameters as operands. The `hal.import.bitcode` flag propagates onto the
+  // `func.func` declaration that `LowerUKernelOpsToCalls` synthesizes;
+  // `RewriteExternCallOpToDynamicImportCallOp` (in LLVMCPU's ConvertToLLVM)
+  // keys on it to *skip* the import-table indirection it otherwise applies to
+  // every external call, letting the call resolve directly against the linked
+  // bitcode at LLVM optimization time. Without it the new framework would
+  // accidentally re-use the legacy runtime-resolved-import path.
   MLIRContext *context = rewriter.getContext();
-  ArrayAttr sourceExecutableObjects = lookUpExecutableObjects(contextualOp);
-  IREE::HAL::ExecutableObjectAttr bitcodeObject =
-      getUKernelBitcode(context, sourceExecutableObjects, name);
-  if (bitcodeObject) {
-    auto attrId = StringAttr::get(context, kHalExecutableObjectsAttrName);
-    ArrayAttr existing = contextualOp->getAttrOfType<ArrayAttr>(attrId);
-    bool alreadyOnOp = false;
-    if (existing) {
-      for (Attribute a : existing) {
-        if (a == bitcodeObject) {
-          alreadyOnOp = true;
-          break;
-        }
-      }
-    }
-    if (!alreadyOnOp) {
-      SmallVector<Attribute> objects;
-      if (existing) {
-        objects.append(existing.begin(), existing.end());
-      }
-      objects.push_back(bitcodeObject);
-      rewriter.modifyOpInPlace(contextualOp, [&] {
-        contextualOp->setAttr(attrId, ArrayAttr::get(context, objects));
-      });
+  auto fnDefAttrs = DictionaryAttr::get(
+      context, {{rewriter.getStringAttr("hal.import.bitcode"),
+                 rewriter.getBoolAttr(true)}});
+
+  // `inner_tiled` with a CPU `DataTiledMMAAttr`: thread the unrolling factors,
+  // outer-K count and ACC stride so the ukernel can loop over the intrinsics.
+  if (auto innerTiled = dyn_cast<IREE::Codegen::InnerTiledOp>(contextualOp)) {
+    if (auto mma = dyn_cast<DataTiledMMAAttr>(innerTiled.getKind())) {
+      return handleInnerTiledMmaUkernel(rewriter, name, innerTiled, mma, inputs,
+                                        outputs, fnDefAttrs);
     }
   }
-  return std::nullopt;
+
+  // Any other op: a plain `ukernel.generic` with no extra operands.
+  DictionaryAttr discardableAttrs =
+      contextualOp->getDiscardableAttrDictionary();
+  auto newOp = rewriter.replaceOpWithNewOp<IREE::Codegen::UKernelGenericOp>(
+      contextualOp, contextualOp->getResults().getTypes(), name, inputs,
+      outputs, otherOperands, fnDefAttrs,
+      /*num_strided_outer_dims=*/0);
+  newOp->setDiscardableAttrs(discardableAttrs);
+  return success();
 }
 
 //===----------------------------------------------------------------------===//
diff --git a/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUTypes.h b/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUTypes.h
index e78face..c0a1425 100644
--- a/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUTypes.h
+++ b/compiler/src/iree/compiler/Codegen/Dialect/CPU/IR/IREECPUTypes.h
@@ -98,6 +98,19 @@
 std::optional<std::tuple<int64_t, int64_t, int64_t>>
 getRowMajorTilesMNKShape(MMAIntrinsic intrinsic);
 
+// Idempotently attaches the bitcode for ukernel `name` as
+// `hal.executable.objects` on `op`, looking it up first in any
+// user-supplied `hal.executable.objects` ancestor, then falling back to
+// the global `EmbeddedDataDirectory` (populated at LLVMCPU plugin init).
+// No-op if `op` already carries a matching entry. Returns true if a matching
+// bitcode was found (and is now attached), false if none exists for `name` —
+// callers use the bool to decide whether a ukernel is actually available.
+// Used by both `LLVMCPUSelectUKernels` (at kernel-config time, so the bitcode
+// is on the op for the rest of codegen) and by
+// `UKernelProviderAttr::createAndReplaceWithUkernelOp` (as a backstop for
+// tests / future paths that bypass SelectUKernels).
+bool attachUKernelBitcodeOnOp(Operation *op, StringRef name);
+
 } // namespace mlir::iree_compiler::IREE::CPU
 
 // clang-format on
diff --git a/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp b/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp
index e60748b..bb97786 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp
+++ b/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp
@@ -68,6 +68,14 @@
     return {};
   }
 
+  // Resolve and attach the bitcode on the op now (at kernel-config time),
+  // so it survives all the way down to `LowerUKernelOpsToCalls` as a
+  // discardable attribute. Mirrors the GPU side
+  // (`ensureUKernelBitcodeAndFinalizeConfig` in
+  // `compiler/src/iree/compiler/Codegen/LLVMGPU/Utils/LLVMGPUSelectUKernels.cpp`)
+  // and makes the configuration-pass output self-contained for lit tests.
+  IREE::CPU::attachUKernelBitcodeOnOp(op, name);
+
   MLIRContext *context = op->getContext();
   return IREE::Codegen::UKernelDescriptorAttr::get(
       context, StringAttr::get(context, name),