[PluginAPI] Experimental support for dynamic compiler plugins (#24892)

Load experimental compiler plugins at runtime with `--iree-load-plugin`
or `IREE_LOAD_PLUGINS`. Validate plugin metadata and registration, and
allow embedded compilers to recover from failed dynamic registrations.

Use one automatically generated compatibility ID derived from the plugin
API headers. This is not a full ABI fingerprint: plugins need matching
IREE and LLVM/MLIR revisions, compatible toolchains, and aligned
ABI-affecting build settings. An attempt is made to document these
requirements and cover loader failures, but it should be considered
experimental.

Co-authored-by: Jelle Schühmacher <schuehmacher@roofline.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>

---------

Signed-off-by: Jelle Schühmacher <schuehmacher@roofline.ai>
Co-authored-by: Florian Walbroel <florian.walbroel@rwth-aachen.de>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/build_tools/bazel/abi_prefix.bzl b/build_tools/bazel/abi_prefix.bzl
index 44d73b1..6d84a81 100644
--- a/build_tools/bazel/abi_prefix.bzl
+++ b/build_tools/bazel/abi_prefix.bzl
@@ -6,8 +6,7 @@
 
 """The logical namespace the compiler's llvm/mlir symbols are renamed into."""
 
-# Any identifier no real namespace uses will do; the 18 is arbitrary and tracks
-# nothing. A plugin resolves against these names, so changing it invalidates
-# every plugin already built: bump it only to force that. CMake takes the same
-# value from IREE_COMPILER_ABI_PREFIX.
+# This is a symbol namespace, not a compatibility version; 18 has no version
+# meaning. Keep it stable across plugin API changes. CMake uses the same default
+# for IREE_COMPILER_ABI_PREFIX and exports the host's value to plugin builds.
 IREE_COMPILER_ABI_PREFIX = "IREE18"
diff --git a/build_tools/cmake/iree_renamed_link.cmake b/build_tools/cmake/iree_renamed_link.cmake
index b5647bc..c993ad0 100644
--- a/build_tools/cmake/iree_renamed_link.cmake
+++ b/build_tools/cmake/iree_renamed_link.cmake
@@ -8,8 +8,8 @@
 #
 # Renaming every llvm/mlir C++ symbol lets libIREECompiler share a process with
 # a foreign LLVM/MLIR. Plugins rename to match so they resolve against it.
-# gen_rename_map.py decides the spelling, shared with Bazel so both builds land
-# on the same ABI.
+# CMake and Bazel share gen_rename_map.py to produce the same symbol names.
+# Matching names alone do not establish C++ ABI compatibility.
 #
 # Everything becomes a static archive first, because that is what llvm-objcopy
 # takes. The linked library cannot be renamed instead: rewriting .dynsym breaks
@@ -93,7 +93,7 @@
 
 # Splits the transitive link closure into archives to rename (STATIC_LIBS_VAR)
 # and everything else, passed through untouched (OTHER_VAR). Imported targets
-# and C archives are safe to pass through: they define no mangled C++ names.
+# are not renamed; callers must ensure they do not need LLVM/MLIR renaming.
 function(iree_collect_static_link_closure STATIC_LIBS_VAR OTHER_VAR)
   set(_worklist ${ARGN})
   set(_visited "")
diff --git a/build_tools/scripts/BUILD.bazel b/build_tools/scripts/BUILD.bazel
index 12890e7..a25500b 100644
--- a/build_tools/scripts/BUILD.bazel
+++ b/build_tools/scripts/BUILD.bazel
@@ -4,7 +4,7 @@
 # See https://llvm.org/LICENSE.txt for license information.
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
 
-load("@rules_python//python:defs.bzl", "py_binary")
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
 
 package(
     default_visibility = ["//visibility:public"],
@@ -14,6 +14,7 @@
 exports_files([
     "amdgpu_device_binaries.py",
     "amdgpu_target_map.py",
+    "gen_plugin_abi_hash.py",
     "generate_vm_isa.py",
 ])
 
@@ -21,3 +22,12 @@
     name = "generate_vm_isa",
     srcs = ["generate_vm_isa.py"],
 )
+
+py_test(
+    name = "gen_plugin_abi_hash_test",
+    srcs = [
+        "gen_plugin_abi_hash.py",
+        "gen_plugin_abi_hash_test.py",
+    ],
+    main = "gen_plugin_abi_hash_test.py",
+)
diff --git a/build_tools/scripts/gen_plugin_abi_hash.py b/build_tools/scripts/gen_plugin_abi_hash.py
new file mode 100755
index 0000000..ad0ac48
--- /dev/null
+++ b/build_tools/scripts/gen_plugin_abi_hash.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+# 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
+"""Emits a header with a hash of the plugin API headers.
+
+This is the single compatibility ID for the entry-point layout, registration
+contract, and selected C++ API headers. There is no manual version to bump.
+
+Only the listed IREE headers are hashed. This is not a full ABI fingerprint:
+plugins must use the compiler's LLVM/MLIR revision and ABI-affecting build
+settings. Symbol renaming isolates namespaces; it does not enforce that match.
+"""
+
+import argparse
+import hashlib
+import pathlib
+
+_GUARD = "IREE_COMPILER_PLUGINAPI_PLUGINABIHASH_H_"
+
+
+def compute_hash(paths: list[str]) -> str:
+    digest = hashlib.sha256()
+    for path in sorted(paths, key=lambda p: pathlib.PurePath(p).name):
+        # Include names and lengths so moving bytes between headers changes
+        # the hash even when the concatenated contents would be identical.
+        for data in (
+            pathlib.PurePath(path).name.encode(),
+            pathlib.Path(path).read_bytes(),
+        ):
+            digest.update(len(data).to_bytes(8, "big"))
+            digest.update(data)
+    return digest.hexdigest()[:16]
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--input", action="append", required=True)
+    parser.add_argument("--out", required=True)
+    args = parser.parse_args()
+
+    out = pathlib.Path(args.out)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    out.write_text(
+        "// Generated by build_tools/scripts/gen_plugin_abi_hash.py.\n"
+        f"#ifndef {_GUARD}\n"
+        f"#define {_GUARD}\n"
+        "\n"
+        "#include <stdint.h>\n"
+        "\n"
+        f"#define IREE_COMPILER_PLUGIN_ABI_HASH UINT64_C(0x{compute_hash(args.input)})\n"
+        "\n"
+        f"#endif // {_GUARD}\n"
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/build_tools/scripts/gen_plugin_abi_hash_test.py b/build_tools/scripts/gen_plugin_abi_hash_test.py
new file mode 100644
index 0000000..1f2f2e8
--- /dev/null
+++ b/build_tools/scripts/gen_plugin_abi_hash_test.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+# 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
+"""Tests for gen_plugin_abi_hash."""
+
+import os
+import pathlib
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, os.path.dirname(__file__))
+import gen_plugin_abi_hash
+
+
+class ComputeHashTest(unittest.TestCase):
+    def setUp(self):
+        self._dir = tempfile.TemporaryDirectory()
+        self.root = pathlib.Path(self._dir.name)
+        self.addCleanup(self._dir.cleanup)
+
+    def _write(self, name: str, text: str) -> str:
+        path = self.root / name
+        path.write_text(text)
+        return str(path)
+
+    def test_content_change_changes_the_hash(self):
+        before = self._write("Client.h", "class A { virtual void f(); };\n")
+        first = gen_plugin_abi_hash.compute_hash([before])
+        self._write("Client.h", "class A { virtual void f(); virtual void g(); };\n")
+        self.assertNotEqual(first, gen_plugin_abi_hash.compute_hash([before]))
+
+    def test_argument_order_does_not_matter(self):
+        a = self._write("Client.h", "a\n")
+        b = self._write("PluginEntryPoint.h", "b\n")
+        self.assertEqual(
+            gen_plugin_abi_hash.compute_hash([a, b]),
+            gen_plugin_abi_hash.compute_hash([b, a]),
+        )
+
+    def test_moving_content_between_headers_changes_the_hash(self):
+        a = self._write("Client.h", "struct S { int x; };\n")
+        b = self._write("PluginEntryPoint.h", "")
+        first = gen_plugin_abi_hash.compute_hash([a, b])
+        self._write("Client.h", "")
+        self._write("PluginEntryPoint.h", "struct S { int x; };\n")
+        self.assertNotEqual(first, gen_plugin_abi_hash.compute_hash([a, b]))
+
+    def test_hash_is_a_short_hex_string(self):
+        a = self._write("Client.h", "a\n")
+        digest = gen_plugin_abi_hash.compute_hash([a])
+        self.assertEqual(len(digest), 16)
+        self.assertRegex(digest, r"^[0-9a-f]{16}$")
+
+    def test_header_boundaries_affect_the_hash(self):
+        a = self._write("A.h", "B.h")
+        b = self._write("B.h", "")
+        first = gen_plugin_abi_hash.compute_hash([a, b])
+        self._write("A.h", "")
+        self._write("B.h", "B.h")
+        self.assertNotEqual(first, gen_plugin_abi_hash.compute_hash([a, b]))
+
+    def test_build_location_does_not_affect_the_hash(self):
+        a = self._write("Client.h", "struct S {};\n")
+        other = self.root / "other-build"
+        other.mkdir()
+        b = other / "Client.h"
+        b.write_bytes(pathlib.Path(a).read_bytes())
+        self.assertEqual(
+            gen_plugin_abi_hash.compute_hash([a]),
+            gen_plugin_abi_hash.compute_hash([str(b)]),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/compiler/lit.cfg.py b/compiler/lit.cfg.py
index e0c9133..9df1c11 100644
--- a/compiler/lit.cfg.py
+++ b/compiler/lit.cfg.py
@@ -38,6 +38,30 @@
     }
 )
 
+# Bazel passes test plugin paths relative to the runfiles root, CMake absolute.
+# Each becomes a substitution: lit's shell does not expand $VAR.
+_test_srcdir = os.environ.get("TEST_SRCDIR", "")
+
+
+def _resolve_test_plugin(key, value):
+    if os.path.isabs(value):
+        return value
+    for root in (_test_srcdir, os.path.join(_test_srcdir, "_main"), os.getcwd()):
+        candidate = os.path.join(root, value)
+        if os.path.exists(candidate):
+            return os.path.abspath(candidate)
+    lit_config.fatal(f"{key}={value} does not resolve from the runfiles root")
+
+
+for _key in sorted(config.environment, key=len, reverse=True):
+    if _key.startswith("IREE_TEST_") and _key.endswith("_PLUGIN"):
+        config.environment[_key] = _resolve_test_plugin(_key, config.environment[_key])
+        config.substitutions.append(("%" + _key.lower(), config.environment[_key]))
+
+# Only set when the build carries the loadable test plugins.
+if config.environment.get("IREE_TEST_DEPS_PLUGIN"):
+    config.available_features.add("iree_dynamic_plugins")
+
 # Use the most preferred temp directory.
 config.test_exec_root = (
     os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR")
diff --git a/compiler/src/iree/compiler/API/Internal/CompilerDriver.cpp b/compiler/src/iree/compiler/API/Internal/CompilerDriver.cpp
index a1d8b29..909c1fd 100644
--- a/compiler/src/iree/compiler/API/Internal/CompilerDriver.cpp
+++ b/compiler/src/iree/compiler/API/Internal/CompilerDriver.cpp
@@ -420,7 +420,7 @@
   mlir::iree_compiler::registerAllDialects(registry);
   mlir::iree_compiler::registerLLVMIRTranslations(registry);
 
-  if (!pluginManager.loadAvailablePlugins()) {
+  if (!pluginManager.loadAvailablePlugins(/*tolerateDynamicFailures=*/true)) {
     fprintf(stderr, "Failed to initialize IREE compiler plugins.\n");
     abort();
   }
@@ -1531,9 +1531,18 @@
   }
 
   llvm::cl::ParseCommandLineOptions(argc, argv, banner);
+  // Reject load flags that were not handled before global initialization.
+  if (!mlir::iree_compiler::verifyDynamicPluginFlags(llvm::errs())) {
+    exit(EXIT_FAILURE);
+  }
 }
 
 void ireeCompilerGlobalInitialize() {
+  // No argv here, so only IREE_LOAD_PLUGINS names plugins. Failures are
+  // logged: a library must not kill its host.
+  (void)mlir::iree_compiler::initializeDynamicPlugins(/*args=*/{},
+                                                      llvm::errs());
+
   if (globalInit) {
     globalInit->refCount.fetch_add(1);
     return;
diff --git a/compiler/src/iree/compiler/API/Internal/IREEOptToolEntryPoint.cpp b/compiler/src/iree/compiler/API/Internal/IREEOptToolEntryPoint.cpp
index 75dc57b..b1c50b2 100644
--- a/compiler/src/iree/compiler/API/Internal/IREEOptToolEntryPoint.cpp
+++ b/compiler/src/iree/compiler/API/Internal/IREEOptToolEntryPoint.cpp
@@ -51,6 +51,13 @@
 
   InitLLVM y(argc, argv);
 
+  // Before CLI parsing: the plugins loaded here still have options to add.
+  if (!mlir::iree_compiler::initializeDynamicPlugins(
+          llvm::ArrayRef<const char *>(const_cast<const char **>(argv), argc),
+          llvm::errs())) {
+    return failure();
+  }
+
   // We support a limited form of the PluginManager, allowing it to perform
   // global initialization and dialect registration.
   mlir::iree_compiler::PluginManager pluginManager;
diff --git a/compiler/src/iree/compiler/Pipelines/BUILD.bazel b/compiler/src/iree/compiler/Pipelines/BUILD.bazel
index 86ba007..34337de 100644
--- a/compiler/src/iree/compiler/Pipelines/BUILD.bazel
+++ b/compiler/src/iree/compiler/Pipelines/BUILD.bazel
@@ -12,6 +12,9 @@
     licenses = ["notice"],  # Apache 2.0
 )
 
+# Hashed into the plugin ABI; see PluginAPI/BUILD.bazel.
+exports_files(["Options.h"])
+
 iree_compiler_cc_library(
     name = "Options",
     srcs = ["Options.cpp"],
diff --git a/compiler/src/iree/compiler/PluginAPI/BUILD.bazel b/compiler/src/iree/compiler/PluginAPI/BUILD.bazel
index 063f2f4..bb42dac 100644
--- a/compiler/src/iree/compiler/PluginAPI/BUILD.bazel
+++ b/compiler/src/iree/compiler/PluginAPI/BUILD.bazel
@@ -5,6 +5,7 @@
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
 
 load("//build_tools/bazel:build_defs.oss.bzl", "iree_compiler_cc_library")
+load("//build_tools/bazel:iree_genrule.bzl", "iree_genrule")
 
 package(
     default_visibility = ["//visibility:public"],
@@ -12,11 +13,45 @@
     licenses = ["notice"],  # Apache 2.0
 )
 
+# Generate the single compatibility ID from the entry-point and C++ API headers.
+iree_genrule(
+    name = "PluginABIHash_h__generate",
+    srcs = [
+        "Client.h",
+        "PluginEntryPoint.h",
+        "//build_tools/scripts:gen_plugin_abi_hash.py",
+        "//compiler/src/iree/compiler/Pipelines:Options.h",
+        "//compiler/src/iree/compiler/Utils:OptionUtils.h",
+    ],
+    outs = ["PluginABIHash.h"],
+    cmd = " ".join([
+        "python3 $(rootpath //build_tools/scripts:gen_plugin_abi_hash.py)",
+        "--input $(rootpath Client.h)",
+        "--input $(rootpath PluginEntryPoint.h)",
+        "--input $(rootpath //compiler/src/iree/compiler/Pipelines:Options.h)",
+        "--input $(rootpath //compiler/src/iree/compiler/Utils:OptionUtils.h)",
+        "--out $(execpath PluginABIHash.h)",
+    ]),
+)
+
+iree_compiler_cc_library(
+    name = "PluginABIHash",
+    hdrs = ["PluginABIHash.h"],
+)
+
+# Plain C and header-only, so a C plugin can use it.
+iree_compiler_cc_library(
+    name = "PluginEntryPoint",
+    hdrs = ["PluginEntryPoint.h"],
+    deps = [":PluginABIHash"],
+)
+
 iree_compiler_cc_library(
     name = "PluginAPI",
     srcs = ["Client.cpp"],
     hdrs = ["Client.h"],
     deps = [
+        ":PluginEntryPoint",
         "//compiler/src/iree/compiler/Pipelines:Options",
         "//compiler/src/iree/compiler/Utils",
         "@llvm-project//llvm:Support",
@@ -33,6 +68,7 @@
     ],
     deps = [
         ":PluginAPI",
+        ":PluginEntryPoint",
         "//compiler/src/iree/compiler/PluginAPI/Config:StaticLinkedPlugins",
         "//compiler/src/iree/compiler/Utils",
         "@llvm-project//llvm:Support",
diff --git a/compiler/src/iree/compiler/PluginAPI/CMakeLists.txt b/compiler/src/iree/compiler/PluginAPI/CMakeLists.txt
index e4e74ec..0de130f 100644
--- a/compiler/src/iree/compiler/PluginAPI/CMakeLists.txt
+++ b/compiler/src/iree/compiler/PluginAPI/CMakeLists.txt
@@ -10,6 +10,41 @@
 
 iree_add_all_subdirs()
 
+iree_genrule(
+  NAME
+    PluginABIHash_h__generate
+  SRCS
+    "${PROJECT_SOURCE_DIR}/build_tools/scripts/gen_plugin_abi_hash.py"
+    "${PROJECT_SOURCE_DIR}/compiler/src/iree/compiler/Pipelines/Options.h"
+    "${PROJECT_SOURCE_DIR}/compiler/src/iree/compiler/Utils/OptionUtils.h"
+    "Client.h"
+    "PluginEntryPoint.h"
+  OUTS
+    PluginABIHash.h
+  CMD
+    "python3 $(rootpath //build_tools/scripts:gen_plugin_abi_hash.py) --input $(rootpath Client.h) --input $(rootpath PluginEntryPoint.h) --input $(rootpath //compiler/src/iree/compiler/Pipelines:Options.h) --input $(rootpath //compiler/src/iree/compiler/Utils:OptionUtils.h) --out $(execpath PluginABIHash.h)"
+)
+
+iree_cc_library(
+  NAME
+    PluginABIHash
+  HDRS
+    "PluginABIHash.h"
+  DEPS
+
+  PUBLIC
+)
+
+iree_cc_library(
+  NAME
+    PluginEntryPoint
+  HDRS
+    "PluginEntryPoint.h"
+  DEPS
+    ::PluginABIHash
+  PUBLIC
+)
+
 iree_cc_library(
   NAME
     PluginAPI
@@ -18,6 +53,7 @@
   SRCS
     "Client.cpp"
   DEPS
+    ::PluginEntryPoint
     LLVMSupport
     iree::compiler::Pipelines::Options
     iree::compiler::Utils
@@ -33,6 +69,7 @@
     "PluginManager.cpp"
   DEPS
     ::PluginAPI
+    ::PluginEntryPoint
     LLVMSupport
     MLIRIR
     iree::compiler::PluginAPI::Config::StaticLinkedPlugins
diff --git a/compiler/src/iree/compiler/PluginAPI/PluginEntryPoint.h b/compiler/src/iree/compiler/PluginAPI/PluginEntryPoint.h
new file mode 100644
index 0000000..a5f01b4
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/PluginEntryPoint.h
@@ -0,0 +1,98 @@
+// 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_PLUGINAPI_PLUGINENTRYPOINT_H_
+#define IREE_COMPILER_PLUGINAPI_PLUGINENTRYPOINT_H_
+
+// Plain C up to the macro, so a C plugin can include it.
+#include <stdbool.h>
+#include <stdint.h>
+
+#include "iree/compiler/PluginAPI/PluginABIHash.h"
+
+// One fixed name, so the loader needs no id before the library is open.
+#define IREE_COMPILER_PLUGIN_INFO_SYMBOL_NAME "iree_get_compiler_plugin_info"
+
+#if defined(_MSC_VER)
+#define IREE_COMPILER_PLUGIN_WEAK
+#define IREE_COMPILER_PLUGIN_EXPORT __declspec(dllexport)
+#else
+#define IREE_COMPILER_PLUGIN_WEAK __attribute__((weak))
+// Plugins are normally built with hidden visibility, which hides this from
+// dlsym.
+#define IREE_COMPILER_PLUGIN_EXPORT __attribute__((visibility("default")))
+#endif
+
+#ifdef __cplusplus
+namespace mlir::iree_compiler {
+class PluginRegistrar;
+} // namespace mlir::iree_compiler
+using IreeCompilerPluginRegistrar = mlir::iree_compiler::PluginRegistrar;
+extern "C" {
+#else
+// Opaque to C; only the compiler dereferences it.
+typedef void IreeCompilerPluginRegistrar;
+#endif
+
+// Experimental plugin entry point; no stable API or ABI is promised.
+//
+// The generated header hash is the only compatibility ID. Keep it first and
+// fixed-width: the loader checks it before reading the rest of the structure.
+// Changes to this header automatically change the ID.
+//
+// The strings are owned by the plugin and live for the process.
+typedef struct IreeCompilerPluginInfo {
+  // Hash of the selected IREE plugin API headers, not a full ABI fingerprint.
+  // LLVM/MLIR revisions and ABI-affecting build settings must also match.
+  uint64_t abiHash;
+  // Unique ID advertised by this library. Use the same ID when registering
+  // its session with PluginRegistrar and selecting it with --iree-plugin.
+  const char *pluginId;
+  // Free-form plugin release string; not checked for compatibility.
+  const char *pluginVersion;
+  // Registers sessions and returns true on success. The registrar is borrowed
+  // only for this call; do not retain its address. Dynamic registrations are
+  // committed only if this callback succeeds and their IDs do not collide
+  // with existing registrations. Other global side effects cannot be rolled
+  // back, so the callback must clean them up on failure. Each ID may be
+  // registered only once within a callback.
+  bool (*registerPlugin)(IreeCompilerPluginRegistrar *registrar);
+} IreeCompilerPluginInfo;
+
+typedef const IreeCompilerPluginInfo *(*IreeCompilerPluginInfoGetter)(void);
+
+#ifdef __cplusplus
+} // extern "C"
+
+// The per-id entry point serves a static link, the fixed one a dlopen. The
+// fixed one is weak: a static link holds many plugins and calls none by that
+// name.
+//
+// Indirect so the id may be a macro: ## does not expand its argument.
+#define IREE_DEFINE_COMPILER_PLUGIN(plugin_id, register_fn, plugin_version)    \
+  IREE_DEFINE_COMPILER_PLUGIN_IMPL(plugin_id, register_fn, plugin_version)
+
+#define IREE_DEFINE_COMPILER_PLUGIN_IMPL(plugin_id, register_fn,               \
+                                         plugin_version)                       \
+  extern "C" IREE_COMPILER_PLUGIN_EXPORT bool                                  \
+  iree_register_compiler_plugin_##plugin_id(                                   \
+      mlir::iree_compiler::PluginRegistrar *registrar) {                       \
+    return register_fn(registrar);                                             \
+  }                                                                            \
+  extern "C" IREE_COMPILER_PLUGIN_EXPORT                                       \
+      IREE_COMPILER_PLUGIN_WEAK const IreeCompilerPluginInfo *                 \
+      iree_get_compiler_plugin_info(void) {                                    \
+    static const IreeCompilerPluginInfo info = {                               \
+        IREE_COMPILER_PLUGIN_ABI_HASH,                                         \
+        #plugin_id,                                                            \
+        plugin_version,                                                        \
+        &iree_register_compiler_plugin_##plugin_id,                            \
+    };                                                                         \
+    return &info;                                                              \
+  }
+#endif // __cplusplus
+
+#endif // IREE_COMPILER_PLUGINAPI_PLUGINENTRYPOINT_H_
diff --git a/compiler/src/iree/compiler/PluginAPI/PluginManager.cpp b/compiler/src/iree/compiler/PluginAPI/PluginManager.cpp
index 3ac9025..b18f260 100644
--- a/compiler/src/iree/compiler/PluginAPI/PluginManager.cpp
+++ b/compiler/src/iree/compiler/PluginAPI/PluginManager.cpp
@@ -6,13 +6,26 @@
 
 #include "iree/compiler/PluginAPI/PluginManager.h"
 
+#include <cinttypes>
+#include <string>
 #include <utility>
 
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+#if !defined(_WIN32)
+#include <dlfcn.h>
+#endif
+
 #include "llvm/ADT/STLExtras.h"
 #include "llvm/ADT/StringSet.h"
+#include "llvm/Support/CommandLine.h"
+#include "llvm/Support/DynamicLibrary.h"
+#include "llvm/Support/Error.h"
+#include "llvm/Support/Process.h"
 #include "llvm/Support/raw_ostream.h"
 #include "mlir/IR/Diagnostics.h"
 #include "mlir/IR/Location.h"
+#include "mlir/IR/MLIRContext.h"
 
 // Declare entrypoints for each statically registered plugin.
 #define HANDLE_PLUGIN_ID(plugin_id)                                            \
@@ -37,16 +50,287 @@
       llvm::cl::cat(category));
 }
 
-PluginManager::PluginManager() {}
+namespace {
 
-bool PluginManager::loadAvailablePlugins() {
+// --iree-load-plugin is parsed before LLVM command-line parsing, so plugins can
+// add options. This sink keeps cl from rejecting it and lists it in --help.
+struct PluginOptionsSink {
+  llvm::SmallVector<std::string> pluginOpts;
+  void bindOptions(OptionsBinder &binder) {
+    static llvm::cl::OptionCategory category("IREE dynamic plugin options");
+    binder.list<std::string>(
+        "iree-load-plugin", pluginOpts,
+        llvm::cl::desc(
+            "Load an experimental compiler plugin library (repeatable). "
+            "Use --iree-print-plugin-info during compilation to list "
+            "IDs; activate a plugin with --iree-plugin=<id>."),
+        llvm::cl::value_desc("path"), llvm::cl::cat(category));
+  }
+  using FromFlags = OptionsFromFlags<PluginOptionsSink>;
+};
+
+} // namespace
+
+IREE_DEFINE_COMPILER_OPTION_FLAGS(PluginOptionsSink);
+
+DynamicPluginRegistry &DynamicPluginRegistry::get() {
+  static DynamicPluginRegistry instance;
+  return instance;
+}
+
+llvm::Error DynamicPluginRegistry::initialize(llvm::ArrayRef<const char *> args,
+                                              EnvPlugins envPlugins) {
+  assert(!initialized && "flags cannot be processed twice");
+  initialized = true;
+
+  // Registered even without a command line, so cl accepts and documents the
+  // flag.
+  (void)PluginOptionsSink::FromFlags::get();
+
+  loadPluginsFromCL(args);
+  if (envPlugins == EnvPlugins::Enabled) {
+    loadPluginPathsFromEnv();
+  }
+  return std::move(loadErrors);
+}
+
+void DynamicPluginRegistry::addPlugin(llvm::Expected<Plugin> plugin) {
+  if (!plugin) {
+    loadErrors = llvm::joinErrors(std::move(loadErrors), plugin.takeError());
+    return;
+  }
+  for (const Plugin &existing : plugins) {
+    // Ignore repeated paths from command-line flags and the environment.
+    if (existing.path == plugin->path) {
+      return;
+    }
+    // Reject duplicate advertised IDs before running registration callbacks.
+    if (existing.pluginId == plugin->pluginId) {
+      loadErrors = llvm::joinErrors(
+          std::move(loadErrors),
+          llvm::createStringError(
+              llvm::inconvertibleErrorCode(),
+              "plugin '%s' reports id '%s', already taken by '%s'",
+              plugin->path.c_str(), plugin->pluginId.c_str(),
+              existing.path.c_str()));
+      return;
+    }
+  }
+  plugins.push_back(std::move(*plugin));
+}
+
+bool initializeDynamicPlugins(llvm::ArrayRef<const char *> args,
+                              llvm::raw_ostream &os) {
+  // The first call loads plugins; subsequent calls reuse its result and ignore
+  // their arguments and any environment changes.
+  static const bool valid = [&] {
+    llvm::Error e = DynamicPluginRegistry::get().initialize(
+        args, DynamicPluginRegistry::EnvPlugins::Enabled);
+    if (!e) {
+      return true;
+    }
+    // logAllUnhandledErrors prefixes only the first error.
+    llvm::handleAllErrors(std::move(e), [&](const llvm::ErrorInfoBase &info) {
+      os << "[IREE Dynamic Plugin ERROR]: " << info.message() << "\n";
+    });
+    return false;
+  }();
+  return valid;
+}
+
+bool DynamicPluginRegistry::registerPlugins(PluginManager *registrar) const {
+  bool success = true;
+  for (const auto &plugin : plugins) {
+    if (registrar->registrations.count(plugin.pluginId)) {
+      llvm::errs() << "[IREE Dynamic Plugin ERROR]: '" << plugin.pluginId
+                   << "' has the same id as an already registered plugin\n";
+      success = false;
+      continue;
+    }
+    // Commit registrations only after the callback succeeds. A failing plugin
+    // must not leave partially registered sessions in an embedded compiler.
+    PluginManager pending;
+    if (!plugin.registerFunction(&pending)) {
+      llvm::errs() << "[IREE Dynamic Plugin ERROR]: registration function of '"
+                   << plugin.pluginId << "' (" << plugin.path << ") failed\n";
+      success = false;
+      continue;
+    }
+    bool unique = true;
+    for (const auto &entry : pending.registrations) {
+      if (registrar->registrations.count(entry.getKey())) {
+        llvm::errs() << "[IREE Dynamic Plugin ERROR]: '" << entry.getKey()
+                     << "' has the same id as an already registered plugin\n";
+        unique = false;
+      }
+    }
+    if (!unique) {
+      success = false;
+      continue;
+    }
+    for (auto &entry : pending.registrations) {
+      registrar->registerPlugin(std::move(entry.getValue()));
+    }
+  }
+  registrationFailed |= !success;
+  return success;
+}
+
+llvm::SmallVector<std::string> DynamicPluginRegistry::getLoadedPlugins() const {
+  llvm::SmallVector<std::string> pluginNames;
+  for (const auto &plugin : plugins) {
+    pluginNames.push_back(plugin.pluginId);
+  }
+  return pluginNames;
+}
+
+bool DynamicPluginRegistry::hasPluginPath(llvm::StringRef path) const {
+  return llvm::any_of(
+      plugins, [&](const Plugin &plugin) { return plugin.path == path; });
+}
+
+bool verifyDynamicPluginFlags(llvm::raw_ostream &os) {
+  const auto &registry = DynamicPluginRegistry::get();
+  bool valid = true;
+  for (const std::string &path :
+       PluginOptionsSink::FromFlags::get().pluginOpts) {
+    if (!registry.hasPluginPath(path)) {
+      os << "[IREE Dynamic Plugin ERROR]: --iree-load-plugin=" << path
+         << " was not loaded; this entry point takes plugins from "
+            "IREE_LOAD_PLUGINS\n";
+      valid = false;
+    }
+  }
+  return valid;
+}
+
+llvm::Expected<DynamicPluginRegistry::Plugin>
+DynamicPluginRegistry::Plugin::loadFromPath(llvm::StringRef path) {
+  Plugin plugin;
+  plugin.path = path.str();
+
+#if defined(_WIN32)
+  return llvm::createStringError(
+      llvm::inconvertibleErrorCode(),
+      "dynamic plugins are not supported on Windows");
+#else
+  // llvm::sys::DynamicLibrary uses RTLD_LAZY, which defers a missing symbol to
+  // its first call. RTLD_LOCAL keeps two plugins carrying the same helper from
+  // binding to each other's copy.
+  plugin.library = ::dlopen(plugin.path.c_str(), RTLD_NOW | RTLD_LOCAL);
+  if (!plugin.library) {
+    return llvm::createStringError(llvm::inconvertibleErrorCode(),
+                                   "could not load plugin library '%s': %s",
+                                   plugin.path.c_str(), ::dlerror());
+  }
+
+  // POSIX guarantees the cast.
+  auto getInfo = reinterpret_cast<IreeCompilerPluginInfoGetter>(
+      ::dlsym(plugin.library, IREE_COMPILER_PLUGIN_INFO_SYMBOL_NAME));
+  if (!getInfo) {
+    return llvm::createStringError(llvm::inconvertibleErrorCode(),
+                                   "plugin '%s' defines no %s; declare it with "
+                                   "IREE_DEFINE_COMPILER_PLUGIN",
+                                   plugin.path.c_str(),
+                                   IREE_COMPILER_PLUGIN_INFO_SYMBOL_NAME);
+  }
+
+  const IreeCompilerPluginInfo *info = getInfo();
+  if (!info) {
+    return llvm::createStringError(llvm::inconvertibleErrorCode(),
+                                   "plugin '%s' returned no plugin info",
+                                   plugin.path.c_str());
+  }
+  if (info->abiHash != IREE_COMPILER_PLUGIN_ABI_HASH) {
+    return llvm::createStringError(
+        llvm::inconvertibleErrorCode(),
+        "plugin '%s' was built against plugin API headers %016" PRIx64
+        ", this compiler has %016" PRIx64
+        "; rebuild the plugin against this compiler",
+        plugin.path.c_str(), info->abiHash, IREE_COMPILER_PLUGIN_ABI_HASH);
+  }
+
+  // Read the remaining fields only after the compatibility ID matches.
+  if (!info->pluginId || !info->pluginVersion || !info->registerPlugin) {
+    return llvm::createStringError(
+        llvm::inconvertibleErrorCode(),
+        "plugin '%s' left a field of IreeCompilerPluginInfo null",
+        plugin.path.c_str());
+  }
+
+  plugin.pluginId = info->pluginId;
+  plugin.registerFunction = info->registerPlugin;
+  return plugin;
+#endif // _WIN32
+}
+
+void DynamicPluginRegistry::loadPluginsFromCL(
+    llvm::ArrayRef<const char *> args) {
+  // Match every spelling cl accepts: one or two dashes, a separated value,
+  // response files. verifyDynamicPluginFlags rejects accepted flags whose
+  // paths were not loaded during initialization.
+  llvm::BumpPtrAllocator alloc;
+  llvm::SmallVector<const char *> expanded(args.begin(), args.end());
+  llvm::cl::ExpansionContext expansion(alloc, llvm::cl::TokenizeGNUCommandLine);
+  if (llvm::Error e = expansion.expandResponseFiles(expanded)) {
+    loadErrors = llvm::joinErrors(std::move(loadErrors), std::move(e));
+  }
+
+  for (size_t i = 1; i < expanded.size(); ++i) {
+    llvm::StringRef arg = expanded[i];
+    // cl treats everything after -- as positional.
+    if (arg == "--") {
+      break;
+    }
+    if (!arg.consume_front("--") && !arg.consume_front("-")) {
+      continue;
+    }
+    if (!arg.consume_front("iree-load-plugin")) {
+      continue;
+    }
+    llvm::StringRef path;
+    if (arg.consume_front("=")) {
+      path = arg;
+    } else if (arg.empty() && i + 1 < expanded.size()) {
+      path = expanded[++i];
+    } else {
+      // A longer flag with the same prefix.
+      continue;
+    }
+    if (path.empty()) {
+      loadErrors = llvm::joinErrors(
+          std::move(loadErrors),
+          llvm::createStringError(llvm::inconvertibleErrorCode(),
+                                  "no path given to --iree-load-plugin"));
+      continue;
+    }
+    addPlugin(Plugin::loadFromPath(path));
+  }
+}
+
+void DynamicPluginRegistry::loadPluginPathsFromEnv() {
+  std::optional<std::string> envVar =
+      llvm::sys::Process::GetEnv("IREE_LOAD_PLUGINS");
+  if (!envVar) {
+    return;
+  }
+  llvm::SmallVector<llvm::StringRef, 4> paths;
+  llvm::StringRef(*envVar).split(paths, ',', -1, /*KeepEmpty=*/false);
+  for (llvm::StringRef path : paths) {
+    addPlugin(Plugin::loadFromPath(path));
+  }
+}
+
+bool PluginManager::loadAvailablePlugins(bool tolerateDynamicFailures) {
 // Initialize static plugins.
 #define HANDLE_PLUGIN_ID(plugin_id)                                            \
   if (!iree_register_compiler_plugin_##plugin_id(this))                        \
     return false;
 #include "iree/compiler/PluginAPI/Config/StaticLinkedPlugins.inc"
 #undef HANDLE_PLUGIN_ID
-  return true;
+
+  bool success = DynamicPluginRegistry::get().registerPlugins(this);
+  return success || tolerateDynamicFailures;
 }
 
 void PluginManager::globalInitialize() {
@@ -73,11 +357,12 @@
   }
 }
 
-llvm::SmallVector<std::string> PluginManager::getLoadedPlugins() {
+llvm::SmallVector<std::string> PluginManager::getLoadedPlugins() const {
   llvm::SmallVector<std::string> plugins;
-#define HANDLE_PLUGIN_ID(plugin_id) plugins.push_back(#plugin_id);
-#include "iree/compiler/PluginAPI/Config/StaticLinkedPlugins.inc"
-#undef HANDLE_PLUGIN_ID
+  for (const auto &entry : registrations) {
+    plugins.push_back(entry.getKey().str());
+  }
+  llvm::sort(plugins);
   return plugins;
 }
 
@@ -167,9 +452,10 @@
     }
     auto foundIt = allPluginSessions.find(pluginId);
     if (foundIt == allPluginSessions.end()) {
-      llvm::errs()
-          << "[IREE plugins error]: could not activate requested IREE plugin '"
-          << pluginId << "' because it is not registered (available plugins: ";
+      llvm::errs() << "[IREE plugins error]: could not activate requested "
+                      "IREE plugin '"
+                   << pluginId
+                   << "' because it is not registered (available plugins: ";
       llvm::interleaveComma(getAvailableIds(), llvm::errs());
       llvm::errs() << ")\n";
       return failure();
diff --git a/compiler/src/iree/compiler/PluginAPI/PluginManager.h b/compiler/src/iree/compiler/PluginAPI/PluginManager.h
index ef19499..c249b3d 100644
--- a/compiler/src/iree/compiler/PluginAPI/PluginManager.h
+++ b/compiler/src/iree/compiler/PluginAPI/PluginManager.h
@@ -8,18 +8,21 @@
 #define IREE_COMPILER_PLUGINAPI_PLUGINMANAGER_H_
 
 #include <optional>
+#include <string>
 #include <string_view>
-#include <vector>
 
 #include "iree/compiler/PluginAPI/Client.h"
 #include "iree/compiler/Utils/OptionUtils.h"
+#include "llvm/ADT/ArrayRef.h"
 #include "llvm/ADT/SmallVector.h"
 #include "llvm/ADT/StringMap.h"
+#include "llvm/ADT/StringRef.h"
+#include "llvm/Support/Error.h"
 
 namespace mlir::iree_compiler {
 
-class PluginManager;
 class PluginManagerSession;
+class PluginManager;
 
 // Command line options for the plugin manager.
 class PluginManagerOptions {
@@ -34,6 +37,60 @@
   using FromFlags = OptionsFromFlags<PluginManagerOptions>;
 };
 
+// Initialized before LLVM command-line parsing so plugins can add options.
+class DynamicPluginRegistry {
+public:
+  DynamicPluginRegistry(const DynamicPluginRegistry &) = delete;
+  DynamicPluginRegistry &operator=(const DynamicPluginRegistry &) = delete;
+
+  enum class EnvPlugins { Disabled, Enabled };
+
+  /// Empty until initialize().
+  static DynamicPluginRegistry &get();
+
+  /// Loads every plugin named in |args| and, if enabled, in IREE_LOAD_PLUGINS.
+  /// Call once. The error joins every failure.
+  [[nodiscard]] llvm::Error initialize(llvm::ArrayRef<const char *> args,
+                                       EnvPlugins envPlugins);
+
+  [[nodiscard]] bool registerPlugins(PluginManager *registrar) const;
+  llvm::SmallVector<std::string> getLoadedPlugins() const;
+  bool hasPluginPath(llvm::StringRef path) const;
+  bool hasRegistrationFailures() const { return registrationFailed; }
+
+private:
+  struct Plugin {
+    std::string path;
+    std::string pluginId;
+    // Never closed: registered code must outlive every session.
+    void *library = nullptr;
+    PluginRegistrationFunction registerFunction = nullptr;
+
+    /// The id is read from the library.
+    static llvm::Expected<Plugin> loadFromPath(llvm::StringRef path);
+  };
+
+  DynamicPluginRegistry() = default;
+
+  void loadPluginsFromCL(llvm::ArrayRef<const char *> args);
+  void loadPluginPathsFromEnv();
+  void addPlugin(llvm::Expected<Plugin> plugin);
+
+  bool initialized = false;
+  mutable bool registrationFailed = false;
+  llvm::Error loadErrors = llvm::Error::success();
+  llvm::SmallVector<Plugin> plugins;
+};
+
+/// Loads once, however often it is called; later |args| are ignored. False if
+/// any plugin failed, with the failures written to |os|.
+bool initializeDynamicPlugins(llvm::ArrayRef<const char *> args,
+                              llvm::raw_ostream &os);
+
+/// Catches an --iree-load-plugin that reached llvm::cl without passing through
+/// initializeDynamicPlugins, as through the C API.
+bool verifyDynamicPluginFlags(llvm::raw_ostream &os);
+
 // Manages global registrations for available plugins.
 // Typically, there will be one PluginManager globally for the compiler, and
 // it is initialized in ireeCompilerGlobalInitialize() based on statically
@@ -48,12 +105,10 @@
 // an MLIRContext is available.
 class PluginManager : public PluginRegistrar {
 public:
-  PluginManager();
-
-  // Initializes the plugin manager. Since this may do shared library opening
-  // and use failable initializers, it can fail. There probably isn't much to
-  // do in that case but crash, but the choice is left to the caller.
-  bool loadAvailablePlugins();
+  // Registers static plugins and previously loaded dynamic plugins. Returns
+  // false on registration failure. With tolerateDynamicFailures, skips failed
+  // dynamic registrations and returns false only for static failures.
+  bool loadAvailablePlugins(bool tolerateDynamicFailures = false);
 
   // Calls through to AbstractPluginRegistration::globalInitialize for all
   // available plugins.
@@ -67,14 +122,15 @@
   // available plugins.
   void initializeCLI();
 
-  // Calls through to AbstractPluginRegistration::registerDialects for all
+  // Calls through to AbstractPluginRegistration::registerGlobalDialects for all
   // available plugins.
   void registerGlobalDialects(DialectRegistry &registry);
 
-  // Gets a list of all loaded plugin names.
-  llvm::SmallVector<std::string> getLoadedPlugins();
+  // Returns successfully registered plugin IDs in sorted order.
+  llvm::SmallVector<std::string> getLoadedPlugins() const;
 
 private:
+  friend class DynamicPluginRegistry;
   friend class PluginManagerSession;
 };
 
diff --git a/compiler/src/iree/compiler/PluginAPI/README.md b/compiler/src/iree/compiler/PluginAPI/README.md
index c2282db..23795df 100644
--- a/compiler/src/iree/compiler/PluginAPI/README.md
+++ b/compiler/src/iree/compiler/PluginAPI/README.md
@@ -2,13 +2,14 @@
 
 This is a work in progress to enable IREE compiler plugin support per
 [RFC - Proposal to Build IREE Compiler Plugin Mechanism](https://github.com/iree-org/iree/issues/12520).
-This document will be replaced with a more comprehensive single-source once
-the work is complete.
+
+Dynamic loading is experimental: the API and C++ ABI may change without
+backward compatibility. Rebuild plugins with the compiler they will run in.
 
 ## Interim Developer Docs
 
 The `PluginManager` mirrors the execution hierarchy of the C API bindings
-(`compiler/bindings/c/iree/compiler/embedding_api`):
+(`compiler/bindings/c/iree/compiler/embedding_api.h`):
 
 * Global Initialization
 * Global CLI setup
@@ -36,8 +37,9 @@
 
 ### Static linking
 
-Plugins can be statically linked into the compiler by way of the
-`-DIREE_COMPILER_PLUGINS=` option. This does two things:
+In CMake, select statically linked compiler plugins with
+`-DIREE_COMPILER_PLUGINS=<id1;id2>`. In Bazel, use
+`--iree_compiler_plugins=<id1,id2>`. Selection does two things:
 
 * Causes the generated `PluginAPI/Config/StaticLinkedPlugins.inc` to have
   a `HANDLE_PLUGIN_ID(plugin_id)` line.
@@ -51,16 +53,99 @@
 
 ### Dynamic linking
 
-(Not yet implemented)
+Dynamic compiler plugins are experimental and supported on Linux and macOS.
+In CMake, enable compiler symbol exports with
+`-DIREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=ON`. Bazel's compiler shared
+library exports these symbols without an additional option.
+Each library is opened with `dlopen()` and queried through one exported
+symbol for its ID and compatibility metadata. A header hash mismatch is
+rejected before registration. Plugins are named on the command line or in the environment:
 
-Dynamic linking proceeds similarly, driven by a combination of environment
-variables, API calls to load plugin libs or pre-parsed CLI flags. For each
-plugin library located in such a way, it will be `dlopen()`'d and the
-corresponding entry point found and used, similar to the static linking case.
+```sh
+iree-compile --iree-load-plugin=/path/to/libmy_plugin.so --iree-plugin=my_id ...
+IREE_LOAD_PLUGINS=/path/to/libmy_plugin.so \
+  iree-compile --iree-plugin=my_id ...
+```
 
-Note that only compilers built with `-DIREE_COMPILER_BUILD_SHARED_LIBS=ON` is
-supported for this case. That carries a number of restrictions and other issues
-that are outside of the immediate scope of plugins.
+Repeat `--iree-load-plugin=<path>` to load multiple libraries, or set
+`IREE_LOAD_PLUGINS` to a comma-separated list of paths. Loading makes a plugin
+available; `--iree-plugin=<id>` activates an explicitly selected plugin for a
+session. Use `--iree-print-plugin-info` during compilation to list available
+and activated IDs.
+
+Embedded users must set `IREE_LOAD_PLUGINS` before the first
+`ireeCompilerGlobalInitialize()` call. Loading happens once per process;
+subsequent initialization calls do not reread the environment or load new
+plugins. `ireeCompilerSessionSetFlags` can select an already registered plugin
+with `--iree-plugin=<id>`, but cannot load a library with `--iree-load-plugin`.
+
+The loader reports load and registration errors. The command-line tools exit
+on these errors. An embedded compiler keeps successful registrations and skips
+failed dynamic plugins. Registrations from a failed callback are discarded;
+callbacks must clean up any other side effects before returning failure.
+Registering the same ID twice within a callback is a programming error and
+aborts the process. `IREE_DEFINE_COMPILER_PLUGIN` defines both entry points so
+one source can support static and dynamic linking.
+
+Both build systems provide `iree_compiler_register_dynamic_plugin`, which
+builds the library and applies the rename described below. An install tree
+provides it through `find_package(IREECompiler)`; see
+`samples/compiler_plugins/out_of_tree_example/README.md`.
+
+#### Build requirements
+
+Build the plugin against the host compiler's exact IREE and LLVM/MLIR revisions,
+including generated headers from that build. Match these compilation settings:
+
+* Target architecture, pointer width, and platform C++ ABI. Using the host's
+  C++ compiler and toolchain is the simplest way to keep these aligned.
+* C++ standard library and its ABI options, such as `_GLIBCXX_USE_CXX11_ABI`,
+  `_GLIBCXX_DEBUG`, or libc++ ABI configuration macros.
+* RTTI and exception handling (`LLVM_ENABLE_RTTI`, `LLVM_ENABLE_EH`, and the
+  effective `-frtti`/`-fno-rtti` and `-fexceptions`/`-fno-exceptions` flags).
+* Assertions (`NDEBUG`) and LLVM options that change header definitions or
+  layouts, such as `LLVM_ENABLE_ABI_BREAKING_CHECKS`. Check the actual compiler
+  flags: `IREE_ENABLE_ASSERTIONS` can enable assertions in a release build.
+* Sanitizer instrumentation and its runtime requirements, when enabled.
+
+In-tree plugins inherit the build settings. External plugins must match them
+explicitly. Matching only `CMAKE_BUILD_TYPE` is insufficient. Optimization levels
+and debug information need not match unless they change one of the settings
+above.
+
+The tools must link the shared compiler library, the default in both build
+systems (`IREE_LINK_COMPILER_SHARED_LIBRARY` in CMake,
+`//compiler/src/iree/compiler/API:link_shared` in Bazel). CMake also requires
+`IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=ON` and
+`IREE_ENABLE_THIN_ARCHIVES=OFF`. Plugin archives must contain position-independent
+code and must not be thin archives: `llvm-objcopy` rewrites their members.
+
+The build rules rename the plugin's `llvm::` and `mlir::` symbols to match the
+compiler and resolve references against its shared library. Do not link another
+copy of LLVM, MLIR, or the IREE compiler into the plugin.
+
+#### Compatibility checks and versions
+
+Use `IREE_DEFINE_COMPILER_PLUGIN(id, register_fn, "version")`. It fills in the
+compatibility fields from the host headers; plugin authors do not maintain
+compatibility version numbers. The supplied version string identifies the
+plugin's own release; it is not checked for compatibility.
+
+`IREE_COMPILER_PLUGIN_ABI_HASH` is the single compatibility ID. The build
+calculates it from `Client.h`, `PluginEntryPoint.h`, `Pipelines/Options.h`, and
+`Utils/OptionUtils.h`, covering both the entry-point layout and the selected
+C++ API headers. There is no manual API or ABI version to bump. IREE maintainers
+must keep the registration contract in `PluginEntryPoint.h` current so contract
+changes also change the ID.
+
+`IREE_COMPILER_ABI_PREFIX` is a symbol namespace, not a compatibility version.
+Keep its default value; the installed CMake package supplies the host's value.
+It does not need a bump when the plugin API changes.
+
+The loader checks the fixed-width header hash before reading the remaining
+entry-point fields or calling registration. The hash does not cover LLVM/MLIR
+headers or build settings. A matching hash and successful symbol resolution do not guarantee ABI compatibility: a mismatched plugin can
+still load and crash or corrupt memory.
 
 ## Extension points
 
@@ -73,17 +158,22 @@
   from `globalInitialize()` below, but it is intended for regular use and
   called out separately to avoid triggering warnings related to use of
   global initialization.
+* `onRegisterDialects(DialectRegistry&)`: Registers the session's dialects before
+  context initialization and activation.
 * `onActivate()` : Called when a plugin is activated for a session, having
   both `options` and `context` available. This is the recommended point to
-  provide a `DialectRegistry` and configure appropriate context hooks for
-  configuring MLIR prior to any parsing or operation creation.
+  configure appropriate context hooks for configuring MLIR prior to any
+  parsing or operation creation.
 
 HAL targets:
 
+* `populateHALTargetDevices()`
 * `populateHALTargetBackends()`
 
 Input dialects:
 
+* `extendInputConversionPreprocessingPassPipeline()`: Adds preprocessing passes
+  for a built-in input type.
 * `extendCustomInputConversionPassPipeline()`: Called to extend a pass pipeline
   with conversion passes for a given conversion type.
 * `populateCustomInputConversionTypes()`: Called to get a list of all
@@ -91,22 +181,31 @@
 * `populateDetectedCustomInputConversionTypes()`: Called to get a list of all
   conversion types this plugin _found_ within a given module
 
+Preprocessing:
+
+* `extendPreprocessingPassPipeline()`: Adds passes at the end of preprocessing.
+
 Less frequently used extension points:
 
 * `static globalInitialize()` : Perform once-only process level initialization,
   regardless of whether a plugin will be activated. This happens before command
   line processing and should only be used to massage process-wide static
   registration like things, as third party libraries may require.
-* `static registerDialects(DialectRegistry&)` : Extends the process wide
-  initial dialect registry. This should not be used unless if absolutely
-  necessary or if interfacing to legacy codebases that require it.
+* `static registerGlobalDialects(DialectRegistry&)` : Extends the process wide
+  initial dialect registry. Prefer `onRegisterDialects()` unless interfacing
+  to legacy codebases that require global registration.
 
 ## Current Status
 
 * Statically linked, named plugins are supported in CMake (with optional
   inclusion).
-* Statically linked, named plugins are hardcoded in Bazel (no optionality).
-* An example in-tree plugin is under `compiler/plugins/example`.
+* Statically linked, named plugins are supported in Bazel (with optional
+  inclusion).
+* Dynamic compiler plugins are experimental and supported on Linux and macOS.
+* An example in-tree plugin is under `samples/compiler_plugins/example` and
+  supports static and dynamic linking from the same source.
 * See `iree_compiler_plugin.cmake` for the CMake integration. Specifically,
   the `-DIREE_COMPILER_PLUGINS=example` flag can be used to statically link
   the example plugin.
+* [out_of_tree_example](../../../../../samples/compiler_plugins/out_of_tree_example/README.md)
+  adds a dialect and pass, with instructions for building against an install tree.
diff --git a/compiler/src/iree/compiler/PluginAPI/test/BUILD.bazel b/compiler/src/iree/compiler/PluginAPI/test/BUILD.bazel
new file mode 100644
index 0000000..940df22
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/BUILD.bazel
@@ -0,0 +1,176 @@
+# 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
+
+load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test")
+load("//build_tools/bazel:enforce_glob.bzl", "enforce_glob")
+load("//build_tools/bazel:iree_lit_test.bzl", "iree_lit_test_suite")
+
+package(
+    features = ["layering_check"],
+    licenses = ["notice"],  # Apache 2.0
+)
+
+# Built here so the tests need no compiler of their own. Plain C and not
+# renamed: their callbacks do not reference compiler C++ symbols.
+[
+    cc_binary(
+        name = plugin,
+        testonly = True,
+        srcs = ["plugins/%s.c" % plugin],
+        linkshared = True,
+    )
+    for plugin in [
+        "invalid_plugin",
+        "layout_mismatch_plugin",
+    ]
+]
+
+[
+    cc_binary(
+        name = plugin,
+        testonly = True,
+        srcs = ["plugins/%s.c" % plugin],
+        linkshared = True,
+        deps = ["//compiler/src/iree/compiler/PluginAPI:PluginEntryPoint"],
+    )
+    for plugin in [
+        "valid_plugin",
+        "abi_hash_mismatch_plugin",
+        "null_field_plugin",
+        "duplicate_id_plugin",
+        "registration_failure_plugin",
+    ]
+]
+
+cc_test(
+    name = "embedding_registration_test",
+    srcs = ["embedding_registration_test.c"],
+    args = [
+        "$(location //lib:IREECompiler)",
+        "$(location :registration_failure_plugin)",
+    ],
+    data = [
+        ":registration_failure_plugin",
+        "//lib:IREECompiler",
+    ],
+    tags = ["skip-bazel_to_cmake"],
+    target_compatible_with = select({
+        "@platforms//os:linux": [],
+        "@platforms//os:macos": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    deps = [
+        "//compiler/bindings/c:headers",
+        "//compiler/bindings/c:loader",
+    ],
+)
+
+cc_binary(
+    name = "static_collision_plugin",
+    testonly = True,
+    srcs = ["plugins/static_collision_plugin.c"],
+    linkshared = True,
+    deps = [
+        "//compiler/src/iree/compiler/PluginAPI:PluginEntryPoint",
+        "//compiler/src/iree/compiler/PluginAPI/Config:StaticLinkedPlugins",
+    ],
+)
+
+cc_test(
+    name = "embedding_static_collision_test",
+    srcs = ["embedding_registration_test.c"],
+    args = [
+        "$(location //lib:IREECompiler)",
+        "$(location :static_collision_plugin)",
+    ],
+    data = [
+        ":static_collision_plugin",
+        "//lib:IREECompiler",
+    ],
+    tags = ["skip-bazel_to_cmake"],
+    target_compatible_with = select({
+        "@platforms//os:linux": [],
+        "@platforms//os:macos": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    deps = [
+        "//compiler/bindings/c:headers",
+        "//compiler/bindings/c:loader",
+    ],
+)
+
+cc_binary(
+    name = "undefined_symbol_plugin",
+    testonly = True,
+    srcs = ["plugins/undefined_symbol_plugin.c"],
+    linkopts = select({
+        # ELF leaves undefined symbols alone; ld64 has to be told.
+        "@platforms//os:macos": ["-Wl,-undefined,dynamic_lookup"],
+        "//conditions:default": [],
+    }),
+    linkshared = True,
+    deps = ["//compiler/src/iree/compiler/PluginAPI:PluginEntryPoint"],
+)
+
+# Out-of-tree plugins are normally built with hidden visibility; the macro must
+# export the entry point itself.
+cc_binary(
+    name = "hidden_visibility_plugin",
+    testonly = True,
+    srcs = ["plugins/hidden_visibility_plugin.cpp"],
+    copts = ["-fvisibility=hidden"],
+    linkshared = True,
+    deps = ["//compiler/src/iree/compiler/PluginAPI:PluginEntryPoint"],
+)
+
+iree_lit_test_suite(
+    name = "lit",
+    srcs = enforce_glob(
+        [
+            "abi_hash_mismatch.txt",
+            "duplicate_id.txt",
+            "invalid_options.txt",
+            "invalid_plugin.txt",
+            "hidden_visibility.txt",
+            "null_field.txt",
+            "undefined_symbol.txt",
+            "valid_plugin.txt",
+            "layout_mismatch.txt",
+        ],
+        include = ["*.txt"],
+    ),
+    cfg = "//compiler:lit.cfg.py",
+    data = [
+        ":abi_hash_mismatch_plugin",
+        ":duplicate_id_plugin",
+        ":hidden_visibility_plugin",
+        ":invalid_plugin",
+        ":layout_mismatch_plugin",
+        ":null_field_plugin",
+        ":undefined_symbol_plugin",
+        ":valid_plugin",
+    ],
+    env = {
+        "IREE_TEST_ABI_HASH_MISMATCH_PLUGIN": "$(rootpath :abi_hash_mismatch_plugin)",
+        "IREE_TEST_VALID_PLUGIN": "$(rootpath :valid_plugin)",
+        "IREE_TEST_INVALID_PLUGIN": "$(rootpath :invalid_plugin)",
+        "IREE_TEST_LAYOUT_MISMATCH_PLUGIN": "$(rootpath :layout_mismatch_plugin)",
+        "IREE_TEST_HIDDEN_VISIBILITY_PLUGIN": "$(rootpath :hidden_visibility_plugin)",
+        "IREE_TEST_UNDEFINED_SYMBOL_PLUGIN": "$(rootpath :undefined_symbol_plugin)",
+        "IREE_TEST_NULL_FIELD_PLUGIN": "$(rootpath :null_field_plugin)",
+        "IREE_TEST_DUPLICATE_ID_PLUGIN": "$(rootpath :duplicate_id_plugin)",
+    },
+    target_compatible_with = select({
+        "@platforms//os:macos": ["@platforms//os:macos"],
+        "@platforms//os:linux": ["@platforms//os:linux"],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    tools = [
+        "//tools:iree-compile",
+        "//tools:iree-opt",
+        "@llvm-project//llvm:FileCheck",
+    ],
+)
diff --git a/compiler/src/iree/compiler/PluginAPI/test/CMakeLists.txt b/compiler/src/iree/compiler/PluginAPI/test/CMakeLists.txt
new file mode 100644
index 0000000..a792201
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/CMakeLists.txt
@@ -0,0 +1,108 @@
+iree_add_all_subdirs()
+
+# Dynamic loading is unsupported on Windows.
+if(NOT IREE_BUILD_TESTS OR WIN32)
+  return()
+endif()
+
+# Built here so the tests need no compiler of their own. Plain C and not
+# renamed: their callbacks do not reference compiler C++ symbols.
+foreach(_PLUGIN "valid_plugin" "invalid_plugin" "layout_mismatch_plugin"
+                "undefined_symbol_plugin" "abi_hash_mismatch_plugin"
+                "null_field_plugin" "duplicate_id_plugin"
+                "registration_failure_plugin" "static_collision_plugin")
+  add_library(iree_compiler_PluginAPI_test_${_PLUGIN} MODULE
+    "plugins/${_PLUGIN}.c")
+  set_target_properties(iree_compiler_PluginAPI_test_${_PLUGIN} PROPERTIES
+    POSITION_INDEPENDENT_CODE ON)
+endforeach()
+
+foreach(_PLUGIN "valid_plugin" "abi_hash_mismatch_plugin" "undefined_symbol_plugin"
+                "null_field_plugin" "duplicate_id_plugin"
+                "registration_failure_plugin" "static_collision_plugin")
+  target_link_libraries(iree_compiler_PluginAPI_test_${_PLUGIN}
+    PRIVATE iree::compiler::PluginAPI::PluginEntryPoint)
+endforeach()
+
+iree_cc_test(
+  NAME embedding_registration_test
+  SRCS "embedding_registration_test.c"
+  ARGS
+    "$<TARGET_FILE:iree::compiler::API::SharedImpl>"
+    "$<TARGET_FILE:iree_compiler_PluginAPI_test_registration_failure_plugin>"
+  DEPS iree::compiler::bindings::c::loader
+)
+add_dependencies(iree_compiler_PluginAPI_test_embedding_registration_test
+  iree::compiler::API::SharedImpl
+  iree_compiler_PluginAPI_test_registration_failure_plugin
+  iree_compiler_PluginAPI_test_static_collision_plugin)
+target_link_libraries(iree_compiler_PluginAPI_test_static_collision_plugin PRIVATE
+  iree::compiler::PluginAPI::Config::StaticLinkedPlugins)
+add_test(NAME "iree/compiler/PluginAPI/test/embedding_static_collision"
+  COMMAND iree_compiler_PluginAPI_test_embedding_registration_test
+    "$<TARGET_FILE:iree::compiler::API::SharedImpl>"
+    "$<TARGET_FILE:iree_compiler_PluginAPI_test_static_collision_plugin>")
+
+foreach(_tool iree-compile iree-opt)
+  add_test(NAME "iree/compiler/PluginAPI/test/${_tool}_registration_failure"
+    COMMAND $<TARGET_FILE:${_tool}>
+      "--iree-load-plugin=$<TARGET_FILE:iree_compiler_PluginAPI_test_registration_failure_plugin>"
+      --help)
+  set_tests_properties("iree/compiler/PluginAPI/test/${_tool}_registration_failure"
+    PROPERTIES WILL_FAIL TRUE)
+endforeach()
+
+# ELF leaves undefined symbols alone; ld64 has to be told.
+if(APPLE)
+  target_link_options(iree_compiler_PluginAPI_test_undefined_symbol_plugin
+    PRIVATE "-Wl,-undefined,dynamic_lookup")
+endif()
+
+# Out-of-tree plugins are normally built with hidden visibility; the macro must
+# export the entry point itself.
+add_library(iree_compiler_PluginAPI_test_hidden_visibility_plugin MODULE
+  "plugins/hidden_visibility_plugin.cpp")
+set_target_properties(iree_compiler_PluginAPI_test_hidden_visibility_plugin
+  PROPERTIES POSITION_INDEPENDENT_CODE ON)
+target_compile_options(iree_compiler_PluginAPI_test_hidden_visibility_plugin
+  PRIVATE "-fvisibility=hidden")
+target_link_libraries(iree_compiler_PluginAPI_test_hidden_visibility_plugin
+  PRIVATE iree::compiler::PluginAPI::PluginEntryPoint)
+
+
+iree_lit_test_suite(
+  NAME
+    lit
+  SRCS
+    "invalid_options.txt"
+    "invalid_plugin.txt"
+    "valid_plugin.txt"
+    "layout_mismatch.txt"
+    "hidden_visibility.txt"
+    "undefined_symbol.txt"
+    "abi_hash_mismatch.txt"
+    "null_field.txt"
+    "duplicate_id.txt"
+  TOOLS
+    FileCheck
+    iree-compile
+    iree-opt
+)
+
+iree_package_ns(_PACKAGE_NS)
+string(REPLACE "::" "/" _PACKAGE_PATH ${_PACKAGE_NS})
+foreach(_TEST_SRC "invalid_plugin.txt" "valid_plugin.txt" "layout_mismatch.txt"
+                  "hidden_visibility.txt" "undefined_symbol.txt"
+                  "abi_hash_mismatch.txt" "null_field.txt" "duplicate_id.txt")
+  set_property(TEST "${_PACKAGE_PATH}/${_TEST_SRC}.test" APPEND PROPERTY
+    ENVIRONMENT
+      "IREE_TEST_VALID_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_valid_plugin>"
+      "IREE_TEST_INVALID_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_invalid_plugin>"
+      "IREE_TEST_LAYOUT_MISMATCH_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_layout_mismatch_plugin>"
+      "IREE_TEST_HIDDEN_VISIBILITY_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_hidden_visibility_plugin>"
+      "IREE_TEST_UNDEFINED_SYMBOL_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_undefined_symbol_plugin>"
+      "IREE_TEST_ABI_HASH_MISMATCH_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_abi_hash_mismatch_plugin>"
+      "IREE_TEST_NULL_FIELD_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_null_field_plugin>"
+      "IREE_TEST_DUPLICATE_ID_PLUGIN=$<TARGET_FILE:iree_compiler_PluginAPI_test_duplicate_id_plugin>"
+  )
+endforeach()
diff --git a/compiler/src/iree/compiler/PluginAPI/test/abi_hash_mismatch.txt b/compiler/src/iree/compiler/PluginAPI/test/abi_hash_mismatch.txt
new file mode 100644
index 0000000..9b1a777
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/abi_hash_mismatch.txt
@@ -0,0 +1,5 @@
+// A mismatched compatibility ID must fail before registration.
+
+// RUN: not iree-opt --iree-load-plugin=%iree_test_abi_hash_mismatch_plugin 2>&1 | FileCheck %s
+
+// CHECK: [IREE Dynamic Plugin ERROR]: plugin{{.*}}was built against plugin API headers 0000000000000000
diff --git a/compiler/src/iree/compiler/PluginAPI/test/duplicate_id.txt b/compiler/src/iree/compiler/PluginAPI/test/duplicate_id.txt
new file mode 100644
index 0000000..448eadb
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/duplicate_id.txt
@@ -0,0 +1,6 @@
+// One library named twice loads once. Two libraries with one id is an error.
+
+// RUN: iree-opt --iree-load-plugin=%iree_test_valid_plugin --iree-load-plugin=%iree_test_valid_plugin --help
+// RUN: not iree-opt --iree-load-plugin=%iree_test_valid_plugin --iree-load-plugin=%iree_test_duplicate_id_plugin 2>&1 | FileCheck %s
+
+// CHECK: [IREE Dynamic Plugin ERROR]: plugin{{.*}}reports id 'sample_plugin', already taken by
diff --git a/compiler/src/iree/compiler/PluginAPI/test/embedding_registration_test.c b/compiler/src/iree/compiler/PluginAPI/test/embedding_registration_test.c
new file mode 100644
index 0000000..a2453dd
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/embedding_registration_test.c
@@ -0,0 +1,33 @@
+// Copyright 2026 The IREE Authors
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "iree/compiler/embedding_api.h"
+#include "iree/compiler/loader.h"
+
+static void check_plugin(const char *id, void *user_data) {
+  if (strcmp(id, "registration_failure") == 0) {
+    *(bool *)user_data = true;
+  }
+}
+
+int main(int argc, char **argv) {
+  if (argc != 3 || setenv("IREE_LOAD_PLUGINS", argv[2], 1) != 0 ||
+      !ireeCompilerLoadLibrary(argv[1])) {
+    return EXIT_FAILURE;
+  }
+  ireeCompilerGlobalInitialize();
+  bool found_failure = false;
+  ireeCompilerEnumeratePlugins(check_plugin, &found_failure);
+  iree_compiler_session_t *session = ireeCompilerSessionCreate();
+  ireeCompilerSessionDestroy(session);
+  ireeCompilerGlobalShutdown();
+  if (found_failure) {
+    fprintf(stderr, "Failed plugin was advertised as registered\n");
+    return EXIT_FAILURE;
+  }
+  return EXIT_SUCCESS;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/hidden_visibility.txt b/compiler/src/iree/compiler/PluginAPI/test/hidden_visibility.txt
new file mode 100644
index 0000000..a6b731d
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/hidden_visibility.txt
@@ -0,0 +1 @@
+// RUN: iree-opt --iree-load-plugin=%iree_test_hidden_visibility_plugin --help
diff --git a/compiler/src/iree/compiler/PluginAPI/test/invalid_options.txt b/compiler/src/iree/compiler/PluginAPI/test/invalid_options.txt
new file mode 100644
index 0000000..291bda9
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/invalid_options.txt
@@ -0,0 +1,12 @@
+// Command line
+// RUN: not iree-opt --iree-load-plugin= 2>&1 | FileCheck %s --check-prefix=NO_PATH
+// RUN: not iree-compile --iree-load-plugin= 2>&1 | FileCheck %s --check-prefix=NO_PATH
+// RUN: not iree-opt --iree-load-plugin=non_existent_path 2>&1 | FileCheck %s --check-prefix=NON_EXISTENT_PATH
+// RUN: not iree-compile --iree-load-plugin=non_existent_path 2>&1 | FileCheck %s --check-prefix=NON_EXISTENT_PATH
+
+// Environment
+// RUN: env IREE_LOAD_PLUGINS=non_existent_path not iree-opt 2>&1 | FileCheck %s --check-prefix=NON_EXISTENT_PATH
+// RUN: env IREE_LOAD_PLUGINS=non_existent_path not iree-compile 2>&1 | FileCheck %s --check-prefix=NON_EXISTENT_PATH
+
+// NO_PATH: [IREE Dynamic Plugin ERROR]: no path given to --iree-load-plugin
+// NON_EXISTENT_PATH: [IREE Dynamic Plugin ERROR]: could not load plugin library 'non_existent_path'
diff --git a/compiler/src/iree/compiler/PluginAPI/test/invalid_plugin.txt b/compiler/src/iree/compiler/PluginAPI/test/invalid_plugin.txt
new file mode 100644
index 0000000..f1e8e60
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/invalid_plugin.txt
@@ -0,0 +1,3 @@
+// RUN: not iree-opt --iree-load-plugin=%iree_test_invalid_plugin 2>&1 | FileCheck %s
+
+// CHECK: [IREE Dynamic Plugin ERROR]: plugin{{.*}}defines no iree_get_compiler_plugin_info
diff --git a/compiler/src/iree/compiler/PluginAPI/test/layout_mismatch.txt b/compiler/src/iree/compiler/PluginAPI/test/layout_mismatch.txt
new file mode 100644
index 0000000..67d1d90
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/layout_mismatch.txt
@@ -0,0 +1,3 @@
+// RUN: not iree-opt --iree-load-plugin=%iree_test_layout_mismatch_plugin 2>&1 | FileCheck %s
+
+// CHECK: [IREE Dynamic Plugin ERROR]: plugin{{.*}}was built against plugin API headers 0000000000000000
diff --git a/compiler/src/iree/compiler/PluginAPI/test/null_field.txt b/compiler/src/iree/compiler/PluginAPI/test/null_field.txt
new file mode 100644
index 0000000..5fc4b71
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/null_field.txt
@@ -0,0 +1,3 @@
+// RUN: not iree-opt --iree-load-plugin=%iree_test_null_field_plugin 2>&1 | FileCheck %s
+
+// CHECK: [IREE Dynamic Plugin ERROR]: plugin{{.*}}left a field of IreeCompilerPluginInfo null
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/abi_hash_mismatch_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/abi_hash_mismatch_plugin.c
new file mode 100644
index 0000000..ac11bfa
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/abi_hash_mismatch_plugin.c
@@ -0,0 +1,23 @@
+// 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
+
+// Claims other headers. Must be refused before registration.
+
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+static bool
+register_other_headers_plugin(IreeCompilerPluginRegistrar *registrar) {
+  (void)registrar;
+  return true;
+}
+
+static const IreeCompilerPluginInfo info = {
+    UINT64_C(0), "from_other_headers", "test", register_other_headers_plugin};
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/duplicate_id_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/duplicate_id_plugin.c
new file mode 100644
index 0000000..3629f59
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/duplicate_id_plugin.c
@@ -0,0 +1,23 @@
+// 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
+
+// Claims valid_plugin's id.
+
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+static bool register_duplicate_plugin(IreeCompilerPluginRegistrar *registrar) {
+  (void)registrar;
+  return true;
+}
+
+static const IreeCompilerPluginInfo info = {IREE_COMPILER_PLUGIN_ABI_HASH,
+                                            "sample_plugin", "duplicate",
+                                            register_duplicate_plugin};
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/hidden_visibility_plugin.cpp b/compiler/src/iree/compiler/PluginAPI/test/plugins/hidden_visibility_plugin.cpp
new file mode 100644
index 0000000..6b2b00c
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/hidden_visibility_plugin.cpp
@@ -0,0 +1,14 @@
+// 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/PluginAPI/PluginEntryPoint.h"
+
+static bool registerHiddenPlugin(mlir::iree_compiler::PluginRegistrar *r) {
+  (void)r;
+  return true;
+}
+
+IREE_DEFINE_COMPILER_PLUGIN(hidden_plugin, registerHiddenPlugin, "test")
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/invalid_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/invalid_plugin.c
new file mode 100644
index 0000000..d861253
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/invalid_plugin.c
@@ -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
+
+// No plugin entry point.
+
+#include <stdbool.h>
+
+bool some_function(void) { return false; }
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/layout_mismatch_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/layout_mismatch_plugin.c
new file mode 100644
index 0000000..f4b533b
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/layout_mismatch_plugin.c
@@ -0,0 +1,21 @@
+// 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
+
+// An incompatible ABI with only a compatibility ID. The loader must reject it
+// before reading fields from the current layout.
+
+#include <stdint.h>
+
+struct IncompatibleIreeCompilerPluginInfo {
+  uint64_t abiHash;
+};
+
+static const struct IncompatibleIreeCompilerPluginInfo info = {UINT64_C(0)};
+
+const struct IncompatibleIreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/null_field_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/null_field_plugin.c
new file mode 100644
index 0000000..34e4c08
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/null_field_plugin.c
@@ -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
+
+// Matching compatibility ID, null plugin ID. Must fail before registration.
+
+#include <stddef.h>
+
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+static bool register_null_field_plugin(IreeCompilerPluginRegistrar *registrar) {
+  (void)registrar;
+  return true;
+}
+
+static const IreeCompilerPluginInfo info = {IREE_COMPILER_PLUGIN_ABI_HASH, NULL,
+                                            "test", register_null_field_plugin};
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/registration_failure_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/registration_failure_plugin.c
new file mode 100644
index 0000000..8318ee0
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/registration_failure_plugin.c
@@ -0,0 +1,21 @@
+// 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/PluginAPI/PluginEntryPoint.h"
+
+static bool register_failure(IreeCompilerPluginRegistrar *registrar) {
+  (void)registrar;
+  return false;
+}
+
+static const IreeCompilerPluginInfo info = {IREE_COMPILER_PLUGIN_ABI_HASH,
+                                            "registration_failure", "test",
+                                            register_failure};
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/static_collision_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/static_collision_plugin.c
new file mode 100644
index 0000000..162a795
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/static_collision_plugin.c
@@ -0,0 +1,29 @@
+// 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 <stdlib.h>
+
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+static const char *const static_ids[] = {
+#define HANDLE_PLUGIN_ID(id) #id,
+#include "iree/compiler/PluginAPI/Config/StaticLinkedPlugins.inc"
+#undef HANDLE_PLUGIN_ID
+    "",
+};
+
+static bool register_collision(IreeCompilerPluginRegistrar *registrar) {
+  // The loader must detect the collision before calling us.
+  abort();
+}
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  static IreeCompilerPluginInfo info = {IREE_COMPILER_PLUGIN_ABI_HASH, NULL,
+                                        "test", register_collision};
+  info.pluginId = static_ids[0];
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/undefined_symbol_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/undefined_symbol_plugin.c
new file mode 100644
index 0000000..6d1ae68
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/undefined_symbol_plugin.c
@@ -0,0 +1,25 @@
+// 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
+
+// Calls a function nothing provides; lazy binding would let it load.
+
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+extern bool iree_test_absent_function(void);
+
+static bool register_undefined_plugin(IreeCompilerPluginRegistrar *registrar) {
+  (void)registrar;
+  return iree_test_absent_function();
+}
+
+static const IreeCompilerPluginInfo info = {IREE_COMPILER_PLUGIN_ABI_HASH,
+                                            "undefined_plugin", "test",
+                                            register_undefined_plugin};
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/plugins/valid_plugin.c b/compiler/src/iree/compiler/PluginAPI/test/plugins/valid_plugin.c
new file mode 100644
index 0000000..8035673
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/plugins/valid_plugin.c
@@ -0,0 +1,23 @@
+// 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
+
+// Plain C, to keep a C plugin loadable.
+
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+
+static bool register_sample_plugin(IreeCompilerPluginRegistrar *registrar) {
+  (void)registrar;
+  return true;
+}
+
+static const IreeCompilerPluginInfo info = {IREE_COMPILER_PLUGIN_ABI_HASH,
+                                            "sample_plugin", "test",
+                                            register_sample_plugin};
+
+IREE_COMPILER_PLUGIN_EXPORT const IreeCompilerPluginInfo *
+iree_get_compiler_plugin_info(void) {
+  return &info;
+}
diff --git a/compiler/src/iree/compiler/PluginAPI/test/undefined_symbol.txt b/compiler/src/iree/compiler/PluginAPI/test/undefined_symbol.txt
new file mode 100644
index 0000000..4c0d63d
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/undefined_symbol.txt
@@ -0,0 +1,4 @@
+// RUN: not iree-opt --iree-load-plugin=%iree_test_undefined_symbol_plugin 2>&1 | FileCheck %s
+
+// CHECK: [IREE Dynamic Plugin ERROR]: could not load plugin library
+// CHECK-SAME: iree_test_absent_function
diff --git a/compiler/src/iree/compiler/PluginAPI/test/valid_plugin.txt b/compiler/src/iree/compiler/PluginAPI/test/valid_plugin.txt
new file mode 100644
index 0000000..92bcb64
--- /dev/null
+++ b/compiler/src/iree/compiler/PluginAPI/test/valid_plugin.txt
@@ -0,0 +1 @@
+// RUN: iree-opt --iree-load-plugin=%iree_test_valid_plugin --help
diff --git a/compiler/src/iree/compiler/Tools/BUILD.bazel b/compiler/src/iree/compiler/Tools/BUILD.bazel
index 5a8acd2..58353ce 100644
--- a/compiler/src/iree/compiler/Tools/BUILD.bazel
+++ b/compiler/src/iree/compiler/Tools/BUILD.bazel
@@ -195,6 +195,7 @@
         "//compiler/bindings/c:headers",
         "//compiler/src/iree/compiler/API/Internal:CompilerDriver",
         "//compiler/src/iree/compiler/Pipelines",
+        "//compiler/src/iree/compiler/PluginAPI:PluginManager",
         "@llvm-project//llvm:Support",
     ],
 )
diff --git a/compiler/src/iree/compiler/Tools/iree_compile_lib.cc b/compiler/src/iree/compiler/Tools/iree_compile_lib.cc
index 94242f2..4abadbf 100644
--- a/compiler/src/iree/compiler/Tools/iree_compile_lib.cc
+++ b/compiler/src/iree/compiler/Tools/iree_compile_lib.cc
@@ -6,12 +6,14 @@
 
 #include "iree/compiler/Tools/iree_compile_lib.h"
 
+#include <cstdlib>
 #include <functional>
 #include <memory>
 #include <string>
 #include <type_traits>
 
 #include "iree/compiler/Pipelines/Pipelines.h"
+#include "iree/compiler/PluginAPI/PluginManager.h"
 #include "iree/compiler/embedding_api.h"
 #include "llvm/Support/CommandLine.h"
 #include "llvm/Support/InitLLVM.h"
@@ -198,7 +200,19 @@
         exit(0);
       }));
 
+  // Before CLI parsing: the plugins loaded here still have options to add.
+  if (!mlir::iree_compiler::initializeDynamicPlugins(
+          llvm::ArrayRef<const char *>(const_cast<const char **>(argv), argc),
+          llvm::errs())) {
+    return EXIT_FAILURE;
+  }
+
   ireeCompilerGlobalInitialize();
+  if (mlir::iree_compiler::DynamicPluginRegistry::get()
+          .hasRegistrationFailures()) {
+    ireeCompilerGlobalShutdown();
+    return EXIT_FAILURE;
+  }
   ireeCompilerGetProcessCLArgs(&argc, const_cast<const char ***>(&argv));
   ireeCompilerSetupGlobalCL(argc, const_cast<const char **>(argv),
                             "IREE compilation driver\n",
diff --git a/compiler/src/iree/compiler/Utils/BUILD.bazel b/compiler/src/iree/compiler/Utils/BUILD.bazel
index 050bed8..19fa760 100644
--- a/compiler/src/iree/compiler/Utils/BUILD.bazel
+++ b/compiler/src/iree/compiler/Utils/BUILD.bazel
@@ -31,6 +31,9 @@
     ],
 )
 
+# Hashed into the plugin ABI; see PluginAPI/BUILD.bazel.
+exports_files(["OptionUtils.h"])
+
 iree_compiler_cc_library(
     name = "Utils",
     srcs = [
diff --git a/docs/website/docs/developers/building/cmake-options.md b/docs/website/docs/developers/building/cmake-options.md
index 71e3992..3fd1965 100644
--- a/docs/website/docs/developers/building/cmake-options.md
+++ b/docs/website/docs/developers/building/cmake-options.md
@@ -140,11 +140,16 @@
 
 * type: BOOL
 
-Experimental. Builds compiler plugins as shared libraries that `iree-compile`
-loads at run time through `--iree-load-plugin`. Defaults to `OFF`. Requires
-`-DIREE_ENABLE_THIN_ARCHIVES=OFF`, because the plugins resolve against a
-renamed copy of the compiler's symbols and the rename cannot rewrite a thin
-archive. See `compiler/src/iree/compiler/PluginAPI/README.md`.
+Enables experimental dynamic compiler plugins and exports the compiler symbols
+that they need. Defaults to `OFF`. Requires
+`IREE_LINK_COMPILER_SHARED_LIBRARY=ON` and `IREE_ENABLE_THIN_ARCHIVES=OFF`.
+Load a plugin with `--iree-load-plugin=<path>` and activate it with
+`--iree-plugin=<id>`.
+
+Plugins must use the host compiler's IREE and LLVM/MLIR revisions and matching
+ABI-affecting build settings. See the
+[compiler plugin documentation](https://github.com/iree-org/iree/blob/main/compiler/src/iree/compiler/PluginAPI/README.md)
+for build requirements and compatibility checks.
 
 ### `IREE_OUTPUT_FORMAT_C`