[Codegen][CPU] SelectUKernels for inner_tiled + --iree-llvmcpu-enable-llvm-ukernels. (#24569)

Wires the new C-bitcode ukernel framework into the LLVMCPU kernel-config
pipeline. Three pieces:

* `LLVMCPUSelectUKernels.{h,cpp}` — when the target config has
`llvm_ukernels = "inner_tiled"`, `selectUKernel` matches
`iree_codegen.inner_tiled` ops carrying a
`#iree_cpu.data_tiled_mma_layout` and returns a `UKernelDescriptorAttr`
whose name is the corresponding `MMAIntrinsic` enum value lowercased
with the `iree_uk_mma_` prefix (e.g.
`MMA_X86_AVX512BF16_1x16x2_F32_BF16` →
`iree_uk_mma_x86_avx512bf16_1x16x2_f32_bf16`). One ukernel per
intrinsic; `intrinsics_{m,n,k}` flow as function arguments and
specialize via bitcode LTO at the call site. Filters out `None` and the
`MMA_GENERIC_*` family — neither has a corresponding ukernel.

* `--iree-llvmcpu-enable-llvm-ukernels=<csv>` CL flag — bound to a new
`LLVMTarget.llvmUkernels` field, serialized into the target config as
`llvm_ukernels = "<csv>"`. Empty (the default) means off; passing
`inner_tiled` opts dispatches into the new framework. The flag name
leaves room for a future `mlir_ukernels`-style additional category (and
for now `inner_tiled` is also the only value). New helper
`hasLlvmUkernel(config, category)` in Codegen/Utils — distinct from the
existing `hasUkernel` to keep the legacy mmt4d ukernels and the new
LLVM-bitcode ukernels on independent enable flags.

* `KernelDispatch.cpp::setRootConfig(InnerTiledOp)` — calls
`selectUKernel` at the start and, on a match, sets the descriptor via
`setUKernelDescriptor`. Tile sizes and translation_info are unchanged:
the ukernel substitutes for the *innermost* intrinsic execution only,
the surrounding tile sizes and pipeline still hold. Bitcode resolution
is deferred to the `#iree_cpu.ukernel_provider` during
`LowerBitcodeUKernelsPass`, matching the GPU split between "match"
(here) and "attach" (provider).

Lit test (`select_ukernel.mlir`) runs
`iree-llvmcpu-select-lowering-strategy` on two `inner_tiled` ops sharing
the same MMA intrinsic but differing in target config: the one with
`llvm_ukernels = "inner_tiled"` picks up the
`iree_uk_mma_x86_avx512bf16_1x16x2_f32_bf16` descriptor; the other does
not, locking in the "off by default" property.

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/LLVMTargetOptions.cpp b/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.cpp
index 21eaa4a..c58cb4e 100644
--- a/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.cpp
+++ b/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.cpp
@@ -196,6 +196,9 @@
   if (ukernels.compare(DEFAULT_ENABLE_UKERNELS) != 0) {
     addString("ukernels", ukernels);
   }
+  if (llvmUkernels.compare(DEFAULT_ENABLE_LLVM_UKERNELS) != 0) {
+    addString("llvm_ukernels", llvmUkernels);
+  }
   if (linkUkernelBitcode != DEFAULT_LINK_UKERNEL_BITCODE) {
     addBool("link_ukernel_bitcode", linkUkernelBitcode);
   }
@@ -327,6 +330,7 @@
   }
 
   target.ukernels = getString("ukernels", target.ukernels, false);
+  target.llvmUkernels = getString("llvm_ukernels", target.llvmUkernels, false);
   target.linkUkernelBitcode =
       getBool("link_ukernel_bitcode", target.linkUkernelBitcode);
   target.enableInnerTiled =
@@ -602,6 +606,20 @@
       llvm::cl::desc("Enables ukernels in the llvmcpu backend. May be "
                      "`default`, `none`, `all`, or a comma-separated list of "
                      "specific unprefixed ukernels to enable, e.g. `mmt4d`."));
+  // TODO(ukernels): this separate flag from `iree-llvmcpu-enable-ukernels`
+  // is a stopgap while the LLVM-bitcode C ukernels are the only new-style
+  // category. Once the MLIR-ukernel category exists, fold both into a single
+  // unified ukernel-enable scheme and remove this flag.
+  binder.opt<std::string>(
+      "iree-llvmcpu-enable-llvm-ukernels", enableLlvmUkernels,
+      llvm::cl::cat(category),
+      llvm::cl::desc(
+          "Enables the new-style LLVM-bitcode C ukernels under "
+          "compiler/plugins/target/LLVMCPU/builtins/ukernel/. "
+          "Comma-separated list of categories to enable, e.g. "
+          "`inner_tiled`. Empty (the default) means none. The companion "
+          "MLIR-ukernel category will share this flag in a future "
+          "extension."));
   binder.opt<bool>(
       "iree-llvmcpu-link-ukernel-bitcode", linkUKernelBitcode,
       llvm::cl::cat(category),
@@ -657,6 +675,7 @@
   target.vectorWidthInBytes = targetVectorWidthInBytes;
   target.maxStackAllocSizeInBytes = targetMaxStackAllocSizeInBytes.value;
   target.ukernels = enableUkernels;
+  target.llvmUkernels = enableLlvmUkernels;
   target.linkUkernelBitcode = linkUKernelBitcode;
   target.enableInnerTiled = enableInnerTiled;
 
diff --git a/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.h b/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.h
index d741272..cb39c88 100644
--- a/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.h
+++ b/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.h
@@ -47,6 +47,12 @@
   static constexpr const char *DEFAULT_ENABLE_UKERNELS = "default";
   static constexpr bool DEFAULT_LINK_UKERNEL_BITCODE = true;
   static constexpr bool DEFAULT_ENABLE_INNER_TILED = false;
+  // The "LLVM ukernels" — i.e. the new-style C-bitcode ukernels under
+  // `compiler/plugins/target/LLVMCPU/builtins/ukernel/`. Empty default
+  // means off; values are a comma-separated list of categories to enable,
+  // e.g. `inner_tiled`. Distinct from the legacy `ukernels` field above,
+  // which controls the runtime-side `mmt4d`-style ukernels.
+  static constexpr const char *DEFAULT_ENABLE_LLVM_UKERNELS = "";
 
   // Default initialize all fields.
   LLVMTarget();
@@ -129,6 +135,11 @@
   // enable, e.g. `mmt4d`.
   std::string ukernels = DEFAULT_ENABLE_UKERNELS;
 
+  // Enables LLVM-bitcode ukernels (the new-style C ukernels under
+  // `compiler/plugins/target/LLVMCPU/builtins/ukernel/`). Comma-separated
+  // list of categories to enable, e.g. `inner_tiled`. Empty = off.
+  std::string llvmUkernels = DEFAULT_ENABLE_LLVM_UKERNELS;
+
   // Link built-in ukernel bitcode libraries into generated executables.
   bool linkUkernelBitcode = DEFAULT_LINK_UKERNEL_BITCODE;
 
@@ -205,6 +216,7 @@
   llvm::cl::PowerOf2ByteSize targetMaxStackAllocSizeInBytes =
       LLVMTarget::DEFAULT_MAX_STACK_ALLOC_SIZE_IN_BYTES;
   std::string enableUkernels = LLVMTarget::DEFAULT_ENABLE_UKERNELS;
+  std::string enableLlvmUkernels = LLVMTarget::DEFAULT_ENABLE_LLVM_UKERNELS;
   bool linkUKernelBitcode = LLVMTarget::DEFAULT_LINK_UKERNEL_BITCODE;
   bool enableInnerTiled = LLVMTarget::DEFAULT_ENABLE_INNER_TILED;
   bool listTargets; // Ignored - used with llvm::cl::ValueDisallowed.
diff --git a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel
index ff9a4b3..6f5095f 100644
--- a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel
+++ b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/BUILD.bazel
@@ -20,6 +20,7 @@
             "lookup_builtin_and_byo.mlir",
             "lower_inner_tiled_to_bitcode_ukernel.mlir",
             "lower_inner_tiled_to_bitcode_ukernel_i8_vnni.mlir",
+            "select_ukernel.mlir",
         ],
         include = ["*.mlir"],
     ),
diff --git a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt
index 9da2113..409e1e1 100644
--- a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt
+++ b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/CMakeLists.txt
@@ -17,6 +17,7 @@
     "lookup_builtin_and_byo.mlir"
     "lower_inner_tiled_to_bitcode_ukernel.mlir"
     "lower_inner_tiled_to_bitcode_ukernel_i8_vnni.mlir"
+    "select_ukernel.mlir"
   TOOLS
     FileCheck
     iree-opt
diff --git a/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/select_ukernel.mlir b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/select_ukernel.mlir
new file mode 100644
index 0000000..6fc0834
--- /dev/null
+++ b/compiler/plugins/target/LLVMCPU/builtins/ukernel/test/select_ukernel.mlir
@@ -0,0 +1,74 @@
+// RUN: iree-opt --pass-pipeline='builtin.module(iree-llvmcpu-select-lowering-strategy)' \
+// RUN:   --split-input-file %s | FileCheck %s
+
+// Drives the LLVMCPU `LLVMCPUSelectLoweringStrategy` pass on
+// `inner_tiled` rooted dispatches carrying a CPU `DataTiledMMAAttr`. The
+// new-style C-bitcode ukernel framework is selected when the target
+// config has `llvm_ukernels = "inner_tiled"` (set by the
+// `--iree-llvmcpu-enable-llvm-ukernels=inner_tiled` CL flag at
+// `iree-compile` time; we set it directly in the test since `iree-opt`
+// doesn't run target-option serialization).
+
+// Enabled case: `selectCPUUKernel` matches the BF16 1x16x2 intrinsic and
+// annotates the op with `iree_codegen.ukernel = …`.
+#executable_target_enabled = #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",
+  llvm_ukernels = "inner_tiled",
+  native_vector_size = 64 : index,
+  target_triple = "x86_64-unknown-unknown-eabi-elf"
+}>
+
+func.func @bf16_inner_tiled_ukernel_enabled(
+    %lhs: tensor<2x4x1x2xbf16>, %rhs: tensor<2x4x16x2xbf16>, %acc: tensor<2x2x1x16xf32>
+  ) -> tensor<2x2x1x16xf32> attributes {hal.executable.target = #executable_target_enabled} {
+  %0 = iree_codegen.inner_tiled ins(%lhs, %rhs) outs(%acc) {
+    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>
+  return %0 : tensor<2x2x1x16xf32>
+}
+// CHECK-LABEL: func.func @bf16_inner_tiled_ukernel_enabled
+// CHECK:         iree_codegen.inner_tiled
+// CHECK-SAME:      iree_codegen.ukernel = #iree_codegen.ukernel_descriptor<"iree_uk_mma_x86_avx512bf16_1x16x2_f32_bf16", bitcode>
+
+// -----
+
+// Disabled case: target config lacks `llvm_ukernels`, so `selectCPUUKernel`
+// returns null and no descriptor is added — the default, keeping the new
+// framework off in plain builds.
+#executable_target_disabled = #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",
+  native_vector_size = 64 : index,
+  target_triple = "x86_64-unknown-unknown-eabi-elf"
+}>
+
+func.func @bf16_inner_tiled_ukernel_disabled(
+    %lhs: tensor<2x4x1x2xbf16>, %rhs: tensor<2x4x16x2xbf16>, %acc: tensor<2x2x1x16xf32>
+  ) -> tensor<2x2x1x16xf32> attributes {hal.executable.target = #executable_target_disabled} {
+  %0 = iree_codegen.inner_tiled ins(%lhs, %rhs) outs(%acc) {
+    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>
+  return %0 : tensor<2x2x1x16xf32>
+}
+// CHECK-LABEL: func.func @bf16_inner_tiled_ukernel_disabled
+// CHECK:         iree_codegen.inner_tiled
+// CHECK-NOT:     iree_codegen.ukernel = #iree_codegen.ukernel_descriptor
diff --git a/compiler/src/iree/compiler/Codegen/LLVMCPU/BUILD.bazel b/compiler/src/iree/compiler/Codegen/LLVMCPU/BUILD.bazel
index 2e82813..a186358 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMCPU/BUILD.bazel
+++ b/compiler/src/iree/compiler/Codegen/LLVMCPU/BUILD.bazel
@@ -68,6 +68,7 @@
         "LLVMCPUMmt4dVectorLowering.cpp",
         "LLVMCPUPeel.cpp",
         "LLVMCPUSelectLoweringStrategy.cpp",
+        "LLVMCPUSelectUKernels.cpp",
         "LLVMCPUSplitReduction.cpp",
         "LLVMCPUSynchronizeSymbolVisibility.cpp",
         "LLVMCPUTile.cpp",
@@ -87,6 +88,7 @@
     hdrs = [
         "DispatchABI.h",
         "KernelDispatch.h",
+        "LLVMCPUSelectUKernels.h",
         "Passes.h",
         "TargetMLTransformInfo.h",
         "Utils.h",
diff --git a/compiler/src/iree/compiler/Codegen/LLVMCPU/CMakeLists.txt b/compiler/src/iree/compiler/Codegen/LLVMCPU/CMakeLists.txt
index 21ee960..5c0ccd3 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMCPU/CMakeLists.txt
+++ b/compiler/src/iree/compiler/Codegen/LLVMCPU/CMakeLists.txt
@@ -45,6 +45,7 @@
   HDRS
     "DispatchABI.h"
     "KernelDispatch.h"
+    "LLVMCPUSelectUKernels.h"
     "Passes.h"
     "TargetMLTransformInfo.h"
     "Utils.h"
@@ -64,6 +65,7 @@
     "LLVMCPUMmt4dVectorLowering.cpp"
     "LLVMCPUPeel.cpp"
     "LLVMCPUSelectLoweringStrategy.cpp"
+    "LLVMCPUSelectUKernels.cpp"
     "LLVMCPUSplitReduction.cpp"
     "LLVMCPUSynchronizeSymbolVisibility.cpp"
     "LLVMCPUTile.cpp"
diff --git a/compiler/src/iree/compiler/Codegen/LLVMCPU/KernelDispatch.cpp b/compiler/src/iree/compiler/Codegen/LLVMCPU/KernelDispatch.cpp
index 6d843e0..8c4791b 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMCPU/KernelDispatch.cpp
+++ b/compiler/src/iree/compiler/Codegen/LLVMCPU/KernelDispatch.cpp
@@ -13,6 +13,7 @@
 #include "iree/compiler/Codegen/Dialect/Codegen/IR/IREECodegenOps.h"
 #include "iree/compiler/Codegen/Dialect/Codegen/IR/IREECodegenTypes.h"
 #include "iree/compiler/Codegen/Interfaces/PartitionableLoopsInterface.h"
+#include "iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.h"
 #include "iree/compiler/Codegen/LLVMCPU/TargetMLTransformInfo.h"
 #include "iree/compiler/Codegen/LLVMCPU/Utils.h"
 #include "iree/compiler/Codegen/Utils/CPUUtils.h"
@@ -3048,6 +3049,17 @@
 static LogicalResult setRootConfig(mlir::FunctionOpInterface entryPointFn,
                                    IREE::Codegen::InnerTiledOp op) {
   assert(!getLoweringConfig(op) && "expected lowering_config is not set");
+  // Annotate the op with a ukernel descriptor if `selectUKernel` matches one.
+  // This is gated by `--iree-llvmcpu-enable-llvm-ukernels=inner_tiled` on the
+  // target config; off by default. Tile sizes and translation_info below stay
+  // the same — the ukernel handles only the innermost intrinsic execution,
+  // and the surrounding tiling continues to do its job. The bitcode is
+  // resolved and attached as `hal.executable.objects` later by the
+  // `#iree_cpu.ukernel_provider` during `LowerBitcodeUKernelsPass`.
+  if (IREE::Codegen::UKernelDescriptorAttr ukernelDescriptor =
+          selectCPUUKernel(op)) {
+    setUKernelDescriptor(op, ukernelDescriptor);
+  }
   SmallVector<int64_t> bounds;
   op.getIterationBounds(bounds);
   unsigned numLoops = bounds.size();
diff --git a/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp b/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp
new file mode 100644
index 0000000..e60748b
--- /dev/null
+++ b/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.cpp
@@ -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/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.h"
+#include "iree/compiler/Codegen/Dialect/CPU/IR/IREECPUDialect.h"
+#include "iree/compiler/Codegen/Dialect/CPU/IR/IREECPUTypes.h"
+#include "iree/compiler/Codegen/Dialect/Codegen/IR/IREECodegenAttrs.h"
+#include "iree/compiler/Codegen/Dialect/Codegen/IR/IREECodegenOps.h"
+#include "iree/compiler/Codegen/Utils/Utils.h"
+#include "iree/compiler/Dialect/HAL/IR/HALTypes.h"
+
+namespace mlir::iree_compiler {
+
+namespace {
+
+// Maps an `IREE::CPU::MMAIntrinsic` to the ukernel function/file name that
+// implements it. The convention (see the README under
+// compiler/plugins/target/LLVMCPU/builtins/ukernel/) is: the enum value,
+// lowercased, with the `iree_uk_` prefix (replacing the `MMA_` in the
+// enum). Returns empty string for `None` and for the type-polymorphic
+// generic-scalar family.
+static std::string getUkernelNameForIntrinsic(IREE::CPU::MMAIntrinsic intr) {
+  StringRef enumName = IREE::CPU::stringifyMMAIntrinsic(intr);
+  // Reject the `None` marker and the generic-scalar family — neither has
+  // a corresponding ukernel.
+  if (enumName == "None" || enumName.starts_with("MMA_GENERIC_")) {
+    return {};
+  }
+  // Strip the `MMA_` prefix and lowercase the rest.
+  if (!enumName.consume_front("MMA_")) {
+    return {};
+  }
+  std::string out = "iree_uk_mma_";
+  out.reserve(out.size() + enumName.size());
+  for (char c : enumName) {
+    out.push_back(
+        static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
+  }
+  return out;
+}
+
+} // namespace
+
+IREE::Codegen::UKernelDescriptorAttr selectCPUUKernel(Operation *op) {
+  auto innerTiled = dyn_cast<IREE::Codegen::InnerTiledOp>(op);
+  if (!innerTiled) {
+    return {};
+  }
+  auto mma = dyn_cast<IREE::CPU::DataTiledMMAAttr>(innerTiled.getKind());
+  if (!mma) {
+    return {};
+  }
+
+  auto execTarget = IREE::HAL::ExecutableTargetAttr::lookup(op);
+  if (!execTarget) {
+    return {};
+  }
+  DictionaryAttr config = execTarget.getConfiguration();
+  if (!hasLlvmUkernel(config, "inner_tiled")) {
+    return {};
+  }
+
+  std::string name = getUkernelNameForIntrinsic(mma.getIntrinsic());
+  if (name.empty()) {
+    return {};
+  }
+
+  MLIRContext *context = op->getContext();
+  return IREE::Codegen::UKernelDescriptorAttr::get(
+      context, StringAttr::get(context, name),
+      IREE::Codegen::UKernelArgumentKind::Bitcode);
+}
+
+} // namespace mlir::iree_compiler
diff --git a/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.h b/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.h
new file mode 100644
index 0000000..c486eb2
--- /dev/null
+++ b/compiler/src/iree/compiler/Codegen/LLVMCPU/LLVMCPUSelectUKernels.h
@@ -0,0 +1,30 @@
+// 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_COMPILER_CODEGEN_LLVMCPU_LLVMCPUSELECTUKERNELS_H_
+#define IREE_COMPILER_CODEGEN_LLVMCPU_LLVMCPUSELECTUKERNELS_H_
+
+#include "iree/compiler/Codegen/Dialect/Codegen/IR/IREECodegenAttrs.h"
+#include "mlir/IR/Operation.h"
+
+namespace mlir::iree_compiler {
+
+// Returns a `UKernelDescriptorAttr` if a built-in LLVMCPU C ukernel applies
+// to `op` AND `--iree-llvmcpu-enable-llvm-ukernels` enables the matching
+// category, otherwise returns null. The caller is expected to attach the
+// returned descriptor (e.g. via `setUKernelDescriptor`); attaching the
+// bitcode itself is deferred to the `#iree_cpu.ukernel_provider`'s
+// `createAndReplaceWithUkernelOp` at `LowerBitcodeUKernelsPass` time.
+//
+// The CPU side mirrors `compiler/src/iree/compiler/Codegen/LLVMGPU/Utils/
+// LLVMGPUSelectUKernels.cpp` but is simpler: there's no per-target-arch
+// bitcode filename (the build features go in the filename via the
+// embedded-TOC entry name) and no shared-memory sizing.
+IREE::Codegen::UKernelDescriptorAttr selectCPUUKernel(Operation *op);
+
+} // namespace mlir::iree_compiler
+
+#endif // IREE_COMPILER_CODEGEN_LLVMCPU_LLVMCPUSELECTUKERNELS_H_
diff --git a/compiler/src/iree/compiler/Codegen/Utils/Utils.cpp b/compiler/src/iree/compiler/Codegen/Utils/Utils.cpp
index 2ce7367..1e30292 100644
--- a/compiler/src/iree/compiler/Codegen/Utils/Utils.cpp
+++ b/compiler/src/iree/compiler/Codegen/Utils/Utils.cpp
@@ -248,6 +248,29 @@
   return false;
 }
 
+bool hasLlvmUkernel(DictionaryAttr targetConfig, StringRef ukernelCategory) {
+  auto enabled = targetConfig.getAs<StringAttr>("llvm_ukernels");
+  StringRef enabledStr = enabled ? enabled.getValue() : StringRef{};
+  // No `default`/`none`/`all` resolution: empty means off, anything else is
+  // a comma-separated list of categories. If the caller passes an empty
+  // `ukernelCategory`, the question is "is any LLVM-ukernel category
+  // enabled?".
+  if (enabledStr.empty()) {
+    return false;
+  }
+  if (ukernelCategory.empty()) {
+    return true;
+  }
+  while (!enabledStr.empty()) {
+    auto split = enabledStr.split(',');
+    if (split.first == ukernelCategory) {
+      return true;
+    }
+    enabledStr = split.second;
+  }
+  return false;
+}
+
 // TODO(dcaballe): If we have to check for a significantly large number of
 // features in the future, we may want to consider a persistent state to carry
 // over processed HAL information or keeping the TTI instance alive and query
diff --git a/compiler/src/iree/compiler/Codegen/Utils/Utils.h b/compiler/src/iree/compiler/Codegen/Utils/Utils.h
index 8a9ab91..a6a32f1 100644
--- a/compiler/src/iree/compiler/Codegen/Utils/Utils.h
+++ b/compiler/src/iree/compiler/Codegen/Utils/Utils.h
@@ -94,6 +94,13 @@
 // is enabled at all.
 bool hasUkernel(DictionaryAttr attr, StringRef ukernelName = "");
 
+// Like `hasUkernel`, but for the LLVM-bitcode ukernels under
+// `compiler/plugins/target/LLVMCPU/builtins/ukernel/` (gated by the
+// `iree-llvmcpu-enable-llvm-ukernels` CL flag / `llvm_ukernels` target
+// config string). Distinct from `hasUkernel` because the two families have
+// independent enable flags and overlapping category names.
+bool hasLlvmUkernel(DictionaryAttr attr, StringRef ukernelCategory = "");
+
 /// Returns true if `attr` has `feature` in its CPU features.
 bool hasFeature(DictionaryAttr targetConfig, StringRef feature);