[Compiler] Rename the compiler's LLVM/MLIR symbols (#24891)

Allow a process that already contains another LLVM to load it. This is a
prerequisite for dynamic plugins.

This is considered experimental, renaming is disabled by default.

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>

---------

Co-authored-by: maxbartel <bartel@roofline.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f59326f..bacf38a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -142,6 +142,10 @@
     "Build optimization profile to apply. One of '', 'lto', 'size'.")
 set(IREE_LTO_MODE "full" CACHE STRING "LTO type, 'thin' or 'full'. Only consulted on clang-like compilers.")
 option(IREE_VISIBILITY_HIDDEN "Builds all C/C++ libraries with hidden visibility" ON)
+option(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS "Experimental: builds libIREECompiler with a renamed, exported LLVM/MLIR ABI so dynamically loaded compiler plugins can resolve compiler symbols" OFF)
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS AND WIN32)
+  message(FATAL_ERROR "IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS is not supported on Windows")
+endif()
 
 #-------------------------------------------------------------------------------
 # Custom runtime allocator configuration
@@ -559,6 +563,11 @@
 option(IREE_ENABLE_SPLIT_DWARF "Enable gsplit-dwarf for debug information if the platform supports it" OFF)
 option(IREE_ENABLE_THIN_ARCHIVES "Enables thin ar archives (elf systems only). Disable for released static archives" OFF)
 option(IREE_LINK_COMPILER_SHARED_LIBRARY "Links IREE tools using the compiler compiled into a shared library" ON)
+# Must match build_tools/bazel/abi_prefix.bzl, which explains the value. A
+# plugin renames its symbols to match, so a consumer needs this value;
+# IREECompilerConfig.cmake exports it.
+set(IREE_COMPILER_ABI_PREFIX "IREE18" CACHE STRING
+    "Logical namespace the compiler's llvm/mlir symbols are renamed into")
 option(IREE_ENABLE_WERROR_FLAG "Enable `-Werror` flag, treat error as warning" ON)
 option(IREE_ENABLE_POSITION_INDEPENDENT_CODE "Enable position independent code" TRUE)
 option(IREE_REVERSE_ITERATION "Reverse iteration over in unordered LLVM containers" OFF)
@@ -570,6 +579,23 @@
       "be unloaded before Tracy finishes, static linking is one workaround)")
 endif()
 
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS AND NOT IREE_LINK_COMPILER_SHARED_LIBRARY)
+  message(SEND_ERROR
+      "IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS requires "
+      "-DIREE_LINK_COMPILER_SHARED_LIBRARY=ON: a plugin resolves the renamed "
+      "ABI from libIREECompiler, which statically linked tools do not carry. "
+      "IREE_ENABLE_COMPILER_TRACING forces static linking, so it cannot be "
+      "combined with dynamic plugins")
+endif()
+
+if(IREE_ENABLE_THIN_ARCHIVES AND IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+  message(SEND_ERROR
+      "IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS requires -DIREE_ENABLE_THIN_ARCHIVES=OFF "
+      "(the symbol rename runs llvm-objcopy over every archive; on a thin "
+      "archive it cannot resolve the member objects, and the archive it "
+      "writes carries member paths relative to the wrong directory)")
+endif()
+
 option(IREE_ENABLE_CCACHE
     "[DEPRECATED: Use CMAKE_<LANG>_COMPILER_LAUNCHER configure options or environment variables instead.] Use ccache if installed."
     OFF)
diff --git a/build_tools/bazel/BUILD.bazel b/build_tools/bazel/BUILD.bazel
index dcf0f30..afffddd 100644
--- a/build_tools/bazel/BUILD.bazel
+++ b/build_tools/bazel/BUILD.bazel
@@ -14,6 +14,20 @@
 
 exports_files(["assert_empty_query.sh"])
 
+# Helper tool for the IREE compiler ABI symbol rename (see renamed_link.bzl).
+py_binary(
+    name = "gen_rename_map",
+    srcs = ["gen_rename_map.py"],
+)
+
+py_test(
+    name = "gen_rename_map_test",
+    srcs = [
+        "gen_rename_map.py",
+        "gen_rename_map_test.py",
+    ],
+)
+
 # Note that the "proper" way to do this is via a query on @bazel_tools,
 # but as with so many things bazel, it doesn't work reliably across versions,
 # variants, etc. So we just define our own since we also own the config
diff --git a/build_tools/bazel/abi_prefix.bzl b/build_tools/bazel/abi_prefix.bzl
new file mode 100644
index 0000000..44d73b1
--- /dev/null
+++ b/build_tools/bazel/abi_prefix.bzl
@@ -0,0 +1,13 @@
+# 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
+
+"""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.
+IREE_COMPILER_ABI_PREFIX = "IREE18"
diff --git a/build_tools/bazel/gen_rename_map.py b/build_tools/bazel/gen_rename_map.py
new file mode 100644
index 0000000..d7410a9
--- /dev/null
+++ b/build_tools/bazel/gen_rename_map.py
@@ -0,0 +1,181 @@
+#!/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
+"""Generates an llvm-objcopy --redefine-syms map for IREE ABI renaming.
+
+Renaming lets the compiler library share a process with another LLVM/MLIR copy,
+TensorFlow's say. Plugins rename to match, so shared symbols resolve against the
+library and MLIR TypeIDs coalesce across the dlopen boundary.
+
+The decision is made on the demangled name, which must mention `llvm::` or
+`mlir::` as a namespace. Matching the mangled string instead would be unsound:
+`_Z9good4mlirv` (`good4mlir()`) contains `4mlir` inside a longer source-name.
+Symbols merely naming an llvm/mlir type in their signature are renamed too, or
+plugin functions would disagree with their call sites.
+
+The inserted component is length-prefixed so the result still demangles:
+
+  _ZN4mlir3fooEv        -> _ZN6IREE184mlir3fooEv    IREE18::mlir::foo()
+  _ZTVN4mlir6WalkerE    -> _ZTVN6IREE184mlir6WalkerE  vtable for IREE18::...
+  _Z3fooN4mlir5ValueE   -> _Z6IREE183fooN4mlir5ValueE IREE18(foo, mlir::Value)
+
+The transformation is a pure function of the mangled name, so the library and
+every renamed plugin archive arrive at the same new name independently.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+
+ITANIUM_MANGLED_PREFIXES = ("__Z", "_Z")
+
+# Special encodings that may precede the nested-name: vtable, VTT, typeinfo,
+# typeinfo name, guard variable, reference temporary, thread-local init and
+# wrapper, and `Z` for function-local scope (these chain, e.g. `GVZN...` is
+# the guard variable of a local static inside a namespaced function). The
+# component is inserted after the `N` so the result stays demanglable.
+# Anything else (thunks and other rare forms) falls back to insertion right
+# after the mangling prefix: the result may not demangle, but the rename
+# stays consistent everywhere.
+_MARKED_NESTED_RE = re.compile(r"^(?:GV|GR|TV|TT|TI|TS|TH|TW|Z)*N")
+
+# Namespace qualifier of llvm:: or mlir:: anywhere in the demangled name,
+# including argument and template positions. The look-behind rejects
+# namespaces that merely end in llvm/mlir (e.g. `myllvm::`).
+_NEEDS_RENAME_RE = re.compile(r"(?<![A-Za-z0-9_])(llvm|mlir)::")
+
+
+def _itanium_component(symbol_prefix: str) -> str:
+    """Returns the length-prefixed Itanium source-name component."""
+    return f"{len(symbol_prefix)}{symbol_prefix}"
+
+
+def _needs_rename(demangled: str) -> bool:
+    return _NEEDS_RENAME_RE.search(demangled) is not None
+
+
+def _split_itanium_name(name: str) -> tuple[str, str] | None:
+    for prefix in ITANIUM_MANGLED_PREFIXES:
+        if name.startswith(prefix):
+            return prefix, name[len(prefix) :]
+    return None
+
+
+def _renamed(name: str, demangled: str, component: str) -> str | None:
+    """Returns the renamed symbol, or None when the symbol is out of scope."""
+    split_name = _split_itanium_name(name)
+    if split_name is None:
+        return None
+    if not _needs_rename(demangled):
+        return None
+
+    prefix, body = split_name
+    marked_nested = _MARKED_NESTED_RE.match(body)
+    if marked_nested:
+        head = marked_nested.group(0)
+        return prefix + head + component + body[len(head) :]
+    return prefix + component + body
+
+
+def _demangle(cxxfilt: str, names: list[str]) -> list[str]:
+    """Demangles names 1:1 via llvm-cxxfilt; failures echo the input name."""
+    if not names:
+        return []
+    result = subprocess.run(
+        [cxxfilt],
+        input="\n".join(names) + "\n",
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+        check=True,
+    )
+    demangled = result.stdout.splitlines()
+    if len(demangled) != len(names):
+        raise RuntimeError(
+            f"{cxxfilt} returned {len(demangled)} lines for {len(names)} symbols"
+        )
+    return demangled
+
+
+def _write_rename_map(renames: set[tuple[str, str]], out_path: str) -> None:
+    # Both builds give each invocation its own output path, so a plain write
+    # suffices.
+    with open(out_path, "w", encoding="utf-8") as f:
+        for old, new in sorted(renames):
+            f.write(f"{old} {new}\n")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(
+        description="Generate an llvm-objcopy --redefine-syms file for IREE ABI renaming.",
+    )
+    parser.add_argument("--nm", required=True, help="Path to llvm-nm.")
+    parser.add_argument("--cxxfilt", required=True, help="Path to llvm-cxxfilt.")
+    parser.add_argument(
+        "--input", required=True, help="Object file or static archive to inspect."
+    )
+    parser.add_argument("--out", required=True, help="Output rename-map path.")
+    parser.add_argument(
+        "--symbol-prefix",
+        required=True,
+        help="Logical ABI prefix (e.g. IREE18); inserted as a length-prefixed "
+        "Itanium component.",
+    )
+    parser.add_argument(
+        "--defined-only",
+        action="store_true",
+        help="Pass --defined-only to llvm-nm before generating the map.",
+    )
+    args = parser.parse_args()
+
+    # The map keys must be the raw assembler-level names, so nm must not
+    # demangle; demangling for the rename decision happens separately.
+    nm_args = [args.nm, "--no-demangle"]
+    if args.defined_only:
+        nm_args.append("--defined-only")
+    nm_args.append(args.input)
+
+    result = subprocess.run(
+        nm_args,
+        check=False,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+    )
+    if result.returncode != 0:
+        # Unsupported inputs, corrupt archives, or toolchain mismatches should
+        # fail the Bazel action with llvm-nm's own diagnostic.
+        sys.stderr.write(result.stderr)
+        return result.returncode
+
+    mangled_names = set()
+    for line in result.stdout.splitlines():
+        fields = line.split()
+        if not fields:
+            continue
+        name = fields[-1]
+        if _split_itanium_name(name) is not None:
+            mangled_names.add(name)
+
+    ordered_names = sorted(mangled_names)
+    demangled_names = _demangle(args.cxxfilt, ordered_names)
+
+    component = _itanium_component(args.symbol_prefix)
+    renames: set[tuple[str, str]] = set()
+    for name, demangled in zip(ordered_names, demangled_names):
+        new_name = _renamed(name, demangled, component)
+        if new_name:
+            renames.add((name, new_name))
+
+    _write_rename_map(renames, args.out)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/build_tools/bazel/gen_rename_map_test.py b/build_tools/bazel/gen_rename_map_test.py
new file mode 100644
index 0000000..d571f4c
--- /dev/null
+++ b/build_tools/bazel/gen_rename_map_test.py
@@ -0,0 +1,170 @@
+#!/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_rename_map."""
+
+import os
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, os.path.dirname(__file__))
+import gen_rename_map
+
+PREFIX = "IREE18"
+COMPONENT = gen_rename_map._itanium_component(PREFIX)
+
+
+class ItaniumComponentTest(unittest.TestCase):
+    def test_component_is_length_prefixed(self):
+        self.assertEqual(COMPONENT, "6IREE18")
+
+    def test_length_digit_follows_the_prefix(self):
+        # A fork setting its own prefix gets the right length digit, so the
+        # prefix is never spelled out with the digit attached.
+        self.assertEqual(gen_rename_map._itanium_component("AB"), "2AB")
+        self.assertEqual(
+            gen_rename_map._itanium_component("LONGERPREFIX"), "12LONGERPREFIX"
+        )
+
+    def test_rename_uses_the_given_component(self):
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "_ZN4mlir3fooEv", "mlir::foo()", gen_rename_map._itanium_component("AB")
+            ),
+            "_ZN2AB4mlir3fooEv",
+        )
+
+
+class NeedsRenameTest(unittest.TestCase):
+    def test_namespace_qualifiers_match(self):
+        self.assertTrue(gen_rename_map._needs_rename("mlir::func::FuncOp::print()"))
+        self.assertTrue(gen_rename_map._needs_rename("foo(mlir::Value)"))
+        self.assertTrue(gen_rename_map._needs_rename("vtable for llvm::raw_ostream"))
+        self.assertTrue(
+            gen_rename_map._needs_rename(
+                "std::vector<mlir::Value>::push_back(mlir::Value&&)"
+            )
+        )
+
+    def test_review_false_positive_good4mlir(self):
+        # `_Z9good4mlirv` has the raw `4mlir` marker but no mlir:: namespace.
+        self.assertFalse(gen_rename_map._needs_rename("good4mlir()"))
+
+    def test_namespace_suffix_does_not_match(self):
+        self.assertFalse(gen_rename_map._needs_rename("myllvm::foo()"))
+        self.assertFalse(gen_rename_map._needs_rename("notmlir::bar()"))
+
+    def test_unrelated_names_do_not_match(self):
+        self.assertFalse(gen_rename_map._needs_rename("iree_compiler::foo()"))
+        self.assertFalse(gen_rename_map._needs_rename("plain_c_symbol"))
+
+
+class RenamedTest(unittest.TestCase):
+    def test_nested_name(self):
+        self.assertEqual(
+            gen_rename_map._renamed("_ZN4mlir3fooEv", "mlir::foo()", COMPONENT),
+            "_ZN" + COMPONENT + "4mlir3fooEv",
+        )
+
+    def test_macho_extra_underscore(self):
+        self.assertEqual(
+            gen_rename_map._renamed("__ZN4mlir3barEv", "mlir::bar()", COMPONENT),
+            "__ZN" + COMPONENT + "4mlir3barEv",
+        )
+
+    def test_vtable_typeinfo_guard(self):
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "_ZTVN4mlir6WalkerE", "vtable for mlir::Walker", COMPONENT
+            ),
+            "_ZTVN" + COMPONENT + "4mlir6WalkerE",
+        )
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "_ZTIN4llvm5ErrorE", "typeinfo for llvm::Error", COMPONENT
+            ),
+            "_ZTIN" + COMPONENT + "4llvm5ErrorE",
+        )
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "_ZGVN4mlir3ctxE", "guard variable for mlir::ctx", COMPONENT
+            ),
+            "_ZGVN" + COMPONENT + "4mlir3ctxE",
+        )
+
+    def test_local_scope_and_guard_chains(self):
+        # Guard variable of a function-local static (the MLIR TypeID pattern).
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "__ZGVZN4mlir6detail14TypeIDResolverIN4llvm5APIntEvE13resolveTypeIDEvE2id",
+                "guard variable for mlir::detail::TypeIDResolver<llvm::APInt, "
+                "void>::resolveTypeID()::id",
+                COMPONENT,
+            ),
+            "__ZGVZN"
+            + COMPONENT
+            + "4mlir6detail14TypeIDResolverIN4llvm5APIntEvE13resolveTypeIDEvE2id",
+        )
+        # The local static itself.
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "_ZZN4mlir3fooEvE5local",
+                "mlir::foo()::local",
+                COMPONENT,
+            ),
+            "_ZZN" + COMPONENT + "4mlir3fooEvE5local",
+        )
+
+    def test_free_function_with_mlir_argument(self):
+        # A plugin function naming MLIR types must agree with its call sites.
+        self.assertEqual(
+            gen_rename_map._renamed(
+                "_Z3fooN4mlir5ValueE", "foo(mlir::Value)", COMPONENT
+            ),
+            "_Z" + COMPONENT + "3fooN4mlir5ValueE",
+        )
+
+    def test_review_false_positive_good4mlir(self):
+        self.assertIsNone(
+            gen_rename_map._renamed("_Z9good4mlirv", "good4mlir()", COMPONENT)
+        )
+
+    def test_non_itanium_symbols_pass_through(self):
+        self.assertIsNone(
+            gen_rename_map._renamed("plain_c_symbol", "plain_c_symbol", COMPONENT)
+        )
+        self.assertIsNone(
+            gen_rename_map._renamed("mlirContextCreate", "mlirContextCreate", COMPONENT)
+        )
+
+    def test_iree_namespace_not_renamed(self):
+        self.assertIsNone(
+            gen_rename_map._renamed("_ZN4iree3bazEv", "iree::baz()", COMPONENT)
+        )
+
+
+class WriteRenameMapTest(unittest.TestCase):
+    def test_writes_sorted_map(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            out_path = os.path.join(temp_dir, "rename.map")
+            gen_rename_map._write_rename_map(
+                {
+                    ("_ZN4mlir3barEv", "_ZN" + COMPONENT + "4mlir3barEv"),
+                    ("_ZN4llvm3fooEv", "_ZN" + COMPONENT + "4llvm3fooEv"),
+                },
+                out_path,
+            )
+            with open(out_path, encoding="utf-8") as f:
+                self.assertEqual(
+                    f.read(),
+                    "_ZN4llvm3fooEv _ZN" + COMPONENT + "4llvm3fooEv\n"
+                    "_ZN4mlir3barEv _ZN" + COMPONENT + "4mlir3barEv\n",
+                )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/build_tools/bazel/renamed_link.bzl b/build_tools/bazel/renamed_link.bzl
new file mode 100644
index 0000000..3e071c8
--- /dev/null
+++ b/build_tools/bazel/renamed_link.bzl
@@ -0,0 +1,466 @@
+# 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
+
+"""C++ provider transforms for the renamed IREE compiler ABI.
+
+The compiler library renames every llvm/mlir C++ symbol before link (see
+gen_rename_map.py), so anything linking against it must rename to match,
+undefined references included.
+
+Rewritten archives are declared as new provider values rather than mutating the
+inputs, keeping Bazel's C++ provider graph intact.
+
+The ABI target and the CcInfo transform stay separate so each can be tested
+alone.
+"""
+
+load("@bazel_tools//tools/cpp:toolchain_utils.bzl", "find_cpp_toolchain", "use_cpp_toolchain")
+load("//build_tools/bazel:abi_prefix.bzl", "IREE_COMPILER_ABI_PREFIX")
+
+RenamedCompilerAbiInfo = provider(fields = {
+    "symbol_prefix": "Logical ABI prefix inserted as an Itanium component.",
+    "provided_owners": "Labels, as strings, of the archives inside the library.",
+})
+
+def _bzlmod_repo_name_matches(workspace_name, repo_name):
+    return (
+        workspace_name.endswith("+" + repo_name) or
+        workspace_name.endswith("~" + repo_name)
+    )
+
+def _provided_by_compiler(owner, compiler_workspace_name):
+    workspace_name = owner.workspace_name
+    owner_text = str(owner)
+
+    if "llvm-project" in workspace_name or "llvm-project" in owner_text:
+        return True
+
+    # libbacktrace is already in the dylib and exports plain C, so let consumers
+    # resolve it there rather than carry every platform backend themselves.
+    if "libbacktrace" in workspace_name or "libbacktrace" in owner_text:
+        return True
+
+    # Whatever repository the compiler target came from is inside the dylib
+    # already. In-tree that is the main workspace; from a plugin repository it
+    # is @iree_core, and there the plugin's own main-workspace archives still
+    # need renaming. The caller keeps its direct deps regardless, so an in-tree
+    # plugin sharing the main workspace with the compiler is not mistaken for
+    # part of it.
+    if compiler_workspace_name != None and workspace_name == compiler_workspace_name:
+        return True
+
+    # Fallbacks for a consumer that names no compiler target.
+    if workspace_name == "iree_core":
+        return True
+    return _bzlmod_repo_name_matches(workspace_name, "iree_core") or \
+           _bzlmod_repo_name_matches(workspace_name, "iree")
+
+def _artifact_suffix(file, suffixes, description):
+    path = file.path
+    for suffix in suffixes:
+        if path.endswith(suffix):
+            return suffix
+    fail("Expected {}, got {}".format(description, file.path))
+
+def _sanitize_path_fragment(value):
+    """Returns a stable file-name fragment.
+
+    Example: `@llvm-project//mlir:IR.pic.a` becomes
+    `_llvm_project__mlir_IR_pic_a`, which is safe to use in declared outputs.
+    """
+    result = ""
+    for i in range(len(value)):
+        c = value[i]
+        if c.isalnum() or c == "_":
+            result += c
+        else:
+            result += "_"
+    return result
+
+def _rename_file(ctx, input_file, output_base, symbol_prefix, defined_only, suffixes, description):
+    suffix = _artifact_suffix(input_file, suffixes, description)
+    output_base = _sanitize_path_fragment(output_base)
+    out = ctx.actions.declare_file(
+        "{}/{}{}".format(ctx.label.name + "_renamed", output_base, suffix),
+    )
+    map_file = ctx.actions.declare_file(
+        "{}/{}.rename_map".format(ctx.label.name + "_renamed", output_base),
+    )
+
+    map_args = ctx.actions.args()
+    map_args.add("--nm", ctx.executable._nm)
+    map_args.add("--cxxfilt", ctx.executable._cxxfilt)
+    map_args.add("--input", input_file)
+    map_args.add("--out", map_file)
+    map_args.add("--symbol-prefix", symbol_prefix)
+    if defined_only:
+        map_args.add("--defined-only")
+    ctx.actions.run(
+        executable = ctx.executable._gen_map,
+        arguments = [map_args],
+        inputs = [input_file],
+        tools = [ctx.executable._nm, ctx.executable._cxxfilt],
+        outputs = [map_file],
+        mnemonic = "RenameMap",
+        progress_message = "Computing llvm/mlir rename map for %s" % input_file.short_path,
+    )
+
+    objcopy_args = ctx.actions.args()
+    objcopy_args.add("--redefine-syms=" + map_file.path)
+    objcopy_args.add(input_file)
+    objcopy_args.add(out)
+    ctx.actions.run(
+        executable = ctx.executable._objcopy,
+        arguments = [objcopy_args],
+        inputs = [input_file, map_file],
+        outputs = [out],
+        mnemonic = "RenameSymbols",
+        progress_message = "Renaming llvm/mlir symbols in %s" % input_file.short_path,
+    )
+
+    return out
+
+def _rename_archive(ctx, archive, output_base, symbol_prefix, defined_only):
+    return _rename_file(
+        ctx,
+        archive,
+        output_base,
+        symbol_prefix,
+        defined_only,
+        [".pic.a", ".lo", ".a", ".lib"],
+        "a static archive-like file",
+    )
+
+def _archive_objects(ctx, objects, output_base):
+    if len(objects) == 0:
+        return None
+    output_base = _sanitize_path_fragment(output_base)
+    out = ctx.actions.declare_file(
+        "{}/{}.a".format(ctx.label.name + "_renamed", output_base),
+    )
+    args = ctx.actions.args()
+    args.add("rcs")
+    args.add(out)
+    args.add_all(objects)
+    ctx.actions.run(
+        executable = ctx.executable._llvm_ar,
+        arguments = [args],
+        inputs = objects,
+        outputs = [out],
+        mnemonic = "ArchiveObjects",
+        progress_message = "Archiving object-file linker input for %s" % ctx.label.name,
+    )
+    return out
+
+def _has_objects(value):
+    return value != None and len(value) != 0
+
+def _library_identity(library):
+    """Returns a de-duplication key, or None for flag/input-only libraries."""
+    parts = []
+    for artifact in [
+        library.static_library,
+        library.pic_static_library,
+        library.dynamic_library,
+        library.interface_library,
+    ]:
+        if artifact != None:
+            parts.append(artifact.path)
+    if _has_objects(library.objects):
+        for artifact in library.objects:
+            parts.append(artifact.path)
+    if _has_objects(library.pic_objects):
+        for artifact in library.pic_objects:
+            parts.append(artifact.path)
+    if len(parts) == 0:
+        return None
+    return "|".join(parts)
+
+def _rename_library_to_link(
+        ctx,
+        cc_toolchain,
+        feature_configuration,
+        library,
+        output_base,
+        symbol_prefix,
+        defined_only,
+        force_alwayslink):
+    static_library = None
+    pic_static_library = None
+    object_static_library = None
+    object_pic_static_library = None
+
+    # llvm-objcopy takes one file, so bare object lists are archived first.
+    if library.static_library:
+        static_library = _rename_archive(
+            ctx,
+            library.static_library,
+            output_base + "_static",
+            symbol_prefix,
+            defined_only,
+        )
+    if library.pic_static_library:
+        pic_static_library = _rename_archive(
+            ctx,
+            library.pic_static_library,
+            output_base + "_pic_static",
+            symbol_prefix,
+            defined_only,
+        )
+    if _has_objects(library.objects) and library.static_library == None:
+        object_archive = _archive_objects(ctx, library.objects, output_base + "_objects")
+        object_static_library = _rename_archive(
+            ctx,
+            object_archive,
+            output_base + "_objects_renamed",
+            symbol_prefix,
+            defined_only,
+        )
+    if _has_objects(library.pic_objects) and library.pic_static_library == None:
+        object_pic_archive = _archive_objects(ctx, library.pic_objects, output_base + "_pic_objects")
+        object_pic_static_library = _rename_archive(
+            ctx,
+            object_pic_archive,
+            output_base + "_pic_objects_renamed",
+            symbol_prefix,
+            defined_only,
+        )
+
+    # Nothing to rewrite.
+    renamed_files = [
+        f
+        for f in [
+            static_library,
+            pic_static_library,
+            object_static_library,
+            object_pic_static_library,
+        ]
+        if f != None
+    ]
+    if not renamed_files:
+        return [library], []
+
+    alwayslink = force_alwayslink or library.alwayslink
+
+    # create_library_to_link mangles its input, so it rejects the _solib_ symlink
+    # Bazel already mangled. Give it the original.
+    dynamic_library = library.resolved_symlink_dynamic_library or library.dynamic_library
+    interface_library = library.resolved_symlink_interface_library or library.interface_library
+
+    libraries = []
+
+    # Recreate the same static/dynamic shape the input had.
+    if (
+        static_library != None or
+        pic_static_library != None or
+        dynamic_library != None or
+        interface_library != None
+    ):
+        libraries.append(cc_common.create_library_to_link(
+            actions = ctx.actions,
+            static_library = static_library,
+            pic_static_library = pic_static_library,
+            dynamic_library = dynamic_library,
+            interface_library = interface_library,
+            cc_toolchain = cc_toolchain if dynamic_library != None or interface_library != None else None,
+            feature_configuration = feature_configuration if dynamic_library != None or interface_library != None else None,
+            alwayslink = alwayslink,
+        ))
+    if object_static_library != None or object_pic_static_library != None:
+        libraries.append(cc_common.create_library_to_link(
+            actions = ctx.actions,
+            static_library = object_static_library,
+            pic_static_library = object_pic_static_library,
+            alwayslink = alwayslink,
+        ))
+    return libraries, renamed_files
+
+def _renamed_cc_info_impl(ctx):
+    # Normally taken from the ABI target so plugins and the compiler agree. The
+    # attr keeps this usable alone, in tests or standalone wiring.
+    symbol_prefix = ctx.attr.symbol_prefix
+    compiler_workspace_name = None
+    provided_owners = {}
+    if ctx.attr.compiler:
+        abi = ctx.attr.compiler[RenamedCompilerAbiInfo]
+        symbol_prefix = abi.symbol_prefix
+        compiler_workspace_name = ctx.attr.compiler.label.workspace_name
+        provided_owners = abi.provided_owners or {}
+    cc_toolchain = find_cpp_toolchain(ctx)
+    feature_configuration = cc_common.configure_features(
+        ctx = ctx,
+        cc_toolchain = cc_toolchain,
+        requested_features = ctx.features,
+        unsupported_features = ctx.disabled_features,
+    )
+
+    direct_owners = {str(dep.label): True for dep in ctx.attr.deps}
+    excluded_owners = {str(dep.label): True for dep in ctx.attr.exclude}
+    linker_inputs = []
+    renamed_files = []
+    seen_libraries = {}
+    library_ordinal = 0
+
+    for dep in ctx.attr.deps:
+        for linker_input in dep[CcInfo].linking_context.linker_inputs.to_list():
+            owner = linker_input.owner
+            owner_label = str(owner)
+            if owner_label in excluded_owners:
+                continue
+
+            # Leaf deps only; their MLIR closure still comes from the dylib.
+            if ctx.attr.direct_only and owner_label not in direct_owners:
+                continue
+
+            # The compiler names what it holds, so anything else belongs to
+            # the plugin however deep in its graph it sits. Direct deps are
+            # kept outright, and the workspace test is the fallback for a
+            # consumer that named no compiler.
+            if not ctx.attr.include_provided and owner_label not in direct_owners:
+                if provided_owners:
+                    if owner_label in provided_owners:
+                        continue
+                elif _provided_by_compiler(owner, compiler_workspace_name):
+                    continue
+
+            libraries = []
+            for library in linker_input.libraries:
+                library_id = _library_identity(library)
+
+                # Flags-only entry. Keep going so those fields survive.
+                if library_id != None:
+                    if library_id in seen_libraries:
+                        continue
+                    seen_libraries[library_id] = True
+
+                renamed_libraries, files = _rename_library_to_link(
+                    ctx,
+                    cc_toolchain,
+                    feature_configuration,
+                    library,
+                    "lib_{}".format(library_ordinal),
+                    symbol_prefix,
+                    ctx.attr.defined_only,
+                    ctx.attr.force_alwayslink,
+                )
+                library_ordinal += 1
+                libraries.extend(renamed_libraries)
+                renamed_files.extend(files)
+
+            if libraries or linker_input.user_link_flags or linker_input.additional_inputs:
+                linker_inputs.append(cc_common.create_linker_input(
+                    owner = owner,
+                    libraries = depset(direct = libraries),
+                    user_link_flags = depset(direct = linker_input.user_link_flags),
+                    additional_inputs = depset(direct = linker_input.additional_inputs),
+                ))
+
+    linking_context = cc_common.create_linking_context(
+        linker_inputs = depset(direct = linker_inputs, order = "topological"),
+    )
+    return [
+        DefaultInfo(files = depset(renamed_files)),
+        CcInfo(linking_context = linking_context),
+    ]
+
+renamed_cc_info = rule(
+    implementation = _renamed_cc_info_impl,
+    doc = (
+        "Low-level CcInfo transform that rewrites selected static archives to " +
+        "the IREE compiler ABI."
+    ),
+    attrs = {
+        "deps": attr.label_list(providers = [CcInfo], default = []),
+        "compiler": attr.label(
+            providers = [[CcInfo, RenamedCompilerAbiInfo]],
+            default = None,
+            doc = "Optional compiler ABI target that supplies the symbol prefix.",
+        ),
+        "exclude": attr.label_list(
+            providers = [CcInfo],
+            default = [],
+            doc = "Linker-input owners to omit entirely.",
+        ),
+        "include_provided": attr.bool(
+            default = False,
+            doc = "Keep archives from repos already provided by libIREECompiler.",
+        ),
+        "direct_only": attr.bool(
+            default = False,
+            doc = "Keep only linker inputs whose owner is one of deps' direct labels.",
+        ),
+        "defined_only": attr.bool(
+            default = False,
+            doc = "Rename only defined symbols in each archive.",
+        ),
+        "force_alwayslink": attr.bool(
+            default = False,
+            doc = "Force replacement static archives into whole-archive linking.",
+        ),
+        "symbol_prefix": attr.string(default = IREE_COMPILER_ABI_PREFIX),
+        "_nm": attr.label(default = "@llvm-project//llvm:llvm-nm", executable = True, cfg = "exec"),
+        "_cxxfilt": attr.label(default = "@llvm-project//llvm:llvm-cxxfilt", executable = True, cfg = "exec"),
+        "_objcopy": attr.label(default = "@llvm-project//llvm:llvm-objcopy", executable = True, cfg = "exec"),
+        "_llvm_ar": attr.label(default = "@llvm-project//llvm:llvm-ar", executable = True, cfg = "exec"),
+        "_gen_map": attr.label(default = "//build_tools/bazel:gen_rename_map", executable = True, cfg = "exec"),
+    },
+    fragments = ["cpp"],
+    toolchains = use_cpp_toolchain(),
+)
+
+def _renamed_compiler_abi_impl(ctx):
+    cc_toolchain = find_cpp_toolchain(ctx)
+    feature_configuration = cc_common.configure_features(
+        ctx = ctx,
+        cc_toolchain = cc_toolchain,
+        requested_features = ctx.features,
+        unsupported_features = ctx.disabled_features,
+    )
+
+    # What the library already holds. A plugin resolves these rather than
+    # carrying its own copy, and anything absent here is the plugin's own.
+    provided_owners = {}
+    if ctx.attr.static_closure:
+        for linker_input in ctx.attr.static_closure[CcInfo].linking_context.linker_inputs.to_list():
+            provided_owners[str(linker_input.owner)] = True
+
+    library_to_link = cc_common.create_library_to_link(
+        actions = ctx.actions,
+        dynamic_library = ctx.file.shared_library,
+        cc_toolchain = cc_toolchain,
+        feature_configuration = feature_configuration,
+    )
+    linker_input = cc_common.create_linker_input(
+        owner = ctx.label,
+        libraries = depset(direct = [library_to_link]),
+    )
+    linking_context = cc_common.create_linking_context(
+        linker_inputs = depset(direct = [linker_input]),
+    )
+    return [
+        DefaultInfo(files = depset([ctx.file.shared_library])),
+        RenamedCompilerAbiInfo(
+            symbol_prefix = ctx.attr.symbol_prefix,
+            provided_owners = provided_owners,
+        ),
+        CcInfo(linking_context = linking_context),
+    ]
+
+iree_renamed_compiler_abi = rule(
+    implementation = _renamed_compiler_abi_impl,
+    doc = "Wraps the renamed libIREECompiler shared library and its ABI prefix.",
+    attrs = {
+        "shared_library": attr.label(allow_single_file = True, mandatory = True),
+        "static_closure": attr.label(
+            providers = [CcInfo],
+            doc = "What the shared library was linked from, so a plugin can " +
+                  "tell the compiler's archives from its own.",
+        ),
+        "symbol_prefix": attr.string(default = IREE_COMPILER_ABI_PREFIX),
+    },
+    fragments = ["cpp"],
+    toolchains = use_cpp_toolchain(),
+)
diff --git a/build_tools/cmake/build_all.sh b/build_tools/cmake/build_all.sh
index 44a93a3..98748c3 100755
--- a/build_tools/cmake/build_all.sh
+++ b/build_tools/cmake/build_all.sh
@@ -30,6 +30,12 @@
 # needed, but some of the deps are too large to enable by default for all
 # developers.
 IREE_TARGET_BACKEND_WEBGPU_SPIRV="${IREE_TARGET_BACKEND_WEBGPU_SPIRV:-${OFF_IF_DARWIN}}"
+# Off by default: the rename relinks all of libIREECompiler, which most builds
+# have no use for.
+IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS="${IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS:-OFF}"
+# Mutually exclusive with the rename: llvm-objcopy cannot follow a thin
+# archive's member paths.
+IREE_ENABLE_THIN_ARCHIVES="${IREE_ENABLE_THIN_ARCHIVES:-ON}"
 # Enable building the `iree-test-deps` target.
 IREE_BUILD_TEST_DEPS="${IREE_BUILD_TEST_DEPS:-1}"
 # If set to 1, exit after CMake configure (useful for generating compile_commands.json).
@@ -58,7 +64,7 @@
   # Enable split dwarf and thin archives for smaller object files and faster
   # linking (for builds with debug info).
   "-DIREE_ENABLE_SPLIT_DWARF=ON"
-  "-DIREE_ENABLE_THIN_ARCHIVES=ON"
+  "-DIREE_ENABLE_THIN_ARCHIVES=${IREE_ENABLE_THIN_ARCHIVES}"
 
   # Enable docs build on the CI. The additional builds are pretty fast and
   # give us early warnings for some types of website publication errors.
@@ -74,6 +80,7 @@
   "-DIREE_TARGET_BACKEND_CUDA=${IREE_TARGET_BACKEND_CUDA}"
   "-DIREE_TARGET_BACKEND_ROCM=${IREE_TARGET_BACKEND_ROCM}"
   "-DIREE_TARGET_BACKEND_WEBGPU_SPIRV=${IREE_TARGET_BACKEND_WEBGPU_SPIRV}"
+  "-DIREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=${IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS}"
 )
 
 # Include the first simulator target's test modules in the initial build.
diff --git a/build_tools/cmake/iree_renamed_link.cmake b/build_tools/cmake/iree_renamed_link.cmake
new file mode 100644
index 0000000..b5647bc
--- /dev/null
+++ b/build_tools/cmake/iree_renamed_link.cmake
@@ -0,0 +1,271 @@
+# 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
+
+# CMake side of the renamed IREE compiler ABI (IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS).
+#
+# 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.
+#
+# Everything becomes a static archive first, because that is what llvm-objcopy
+# takes. The linked library cannot be renamed instead: rewriting .dynsym breaks
+# the ELF hash sections.
+
+# Target names reach declared outputs, and some contain dots (`obj.MLIRCAPIIR`).
+function(iree_renamed_link_sanitize_name VALUE OUT_VAR)
+  string(REGEX REPLACE "[^A-Za-z0-9_]" "_" _result "${VALUE}")
+  set(${OUT_VAR} "${_result}" PARENT_SCOPE)
+endfunction()
+
+# Evaluates $<BOOL:...>, where a path is true. if() would call it false under
+# CMP0054, inverting the very paths LLVM guards its link flags with.
+function(iree_renamed_link_genex_bool VALUE OUT_VAR)
+  string(TOUPPER "${VALUE}" _upper)
+  if(_upper STREQUAL "" OR _upper STREQUAL "0" OR _upper STREQUAL "FALSE"
+     OR _upper STREQUAL "OFF" OR _upper STREQUAL "N" OR _upper STREQUAL "NO"
+     OR _upper STREQUAL "IGNORE" OR _upper STREQUAL "NOTFOUND"
+     OR _upper MATCHES "-NOTFOUND$")
+    set(${OUT_VAR} FALSE PARENT_SCOPE)
+  else()
+    set(${OUT_VAR} TRUE PARENT_SCOPE)
+  endif()
+endfunction()
+
+# Rewrites one static archive to the renamed ABI.
+#
+# Parameters:
+#   OUT_VAR: variable receiving the renamed archive path.
+#   NAME: base name for the outputs (must be unique in this directory).
+#   INPUT: archive path (generator expressions allowed).
+#   DEPENDS: extra dependencies of the rename actions.
+function(iree_renamed_archive OUT_VAR)
+  cmake_parse_arguments(_RULE "" "NAME;INPUT" "DEPENDS" ${ARGN})
+  set(_dir "${CMAKE_CURRENT_BINARY_DIR}/renamed")
+  set(_map "${_dir}/${_RULE_NAME}.rename_map")
+  set(_out "${_dir}/lib${_RULE_NAME}.renamed.a")
+  set(_script "${IREE_ROOT_DIR}/build_tools/bazel/gen_rename_map.py")
+  add_custom_command(
+    OUTPUT "${_map}"
+    COMMAND
+      "${Python3_EXECUTABLE}" "${_script}"
+      --nm "$<TARGET_FILE:llvm-nm>"
+      --cxxfilt "$<TARGET_FILE:llvm-cxxfilt>"
+      --input "${_RULE_INPUT}"
+      --out "${_map}"
+      --symbol-prefix "${IREE_COMPILER_ABI_PREFIX}"
+    DEPENDS "${_RULE_INPUT}" "${_script}" llvm-nm llvm-cxxfilt ${_RULE_DEPENDS}
+    COMMENT "Computing llvm/mlir rename map for ${_RULE_NAME}"
+  )
+  add_custom_command(
+    OUTPUT "${_out}"
+    COMMAND
+      "$<TARGET_FILE:llvm-objcopy>" "--redefine-syms=${_map}"
+      "${_RULE_INPUT}" "${_out}"
+    DEPENDS "${_RULE_INPUT}" "${_map}" llvm-objcopy ${_RULE_DEPENDS}
+    COMMENT "Renaming llvm/mlir symbols in ${_RULE_NAME}"
+  )
+  set(${OUT_VAR} "${_out}" PARENT_SCOPE)
+endfunction()
+
+# As iree_renamed_archive, but starting from an object library's objects.
+function(iree_renamed_archive_from_objects OUT_VAR)
+  cmake_parse_arguments(_RULE "" "NAME;TARGET" "" ${ARGN})
+  set(_dir "${CMAKE_CURRENT_BINARY_DIR}/renamed")
+  set(_archive "${_dir}/lib${_RULE_NAME}.objects.a")
+  add_custom_command(
+    OUTPUT "${_archive}"
+    COMMAND "$<TARGET_FILE:llvm-ar>" rcs "${_archive}"
+            "$<TARGET_OBJECTS:${_RULE_TARGET}>"
+    DEPENDS "$<TARGET_OBJECTS:${_RULE_TARGET}>" ${_RULE_TARGET} llvm-ar
+    COMMAND_EXPAND_LISTS
+    COMMENT "Archiving objects of ${_RULE_TARGET}"
+  )
+  iree_renamed_archive(_renamed
+    NAME "${_RULE_NAME}"
+    INPUT "${_archive}"
+  )
+  set(${OUT_VAR} "${_renamed}" PARENT_SCOPE)
+endfunction()
+
+# 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.
+function(iree_collect_static_link_closure STATIC_LIBS_VAR OTHER_VAR)
+  set(_worklist ${ARGN})
+  set(_visited "")
+  set(_static_libs "")
+  set(_other "")
+  while(_worklist)
+    list(POP_FRONT _worklist _item)
+    if(_item IN_LIST _visited)
+      continue()
+    endif()
+    list(APPEND _visited "${_item}")
+    # Directory-scope markers CMake embeds in LINK_LIBRARIES values.
+    if(_item MATCHES "^::@")
+      continue()
+    endif()
+    if(_item MATCHES "^\\$<LINK_ONLY:(.+)>$")
+      list(APPEND _worklist "${CMAKE_MATCH_1}")
+      continue()
+    endif()
+    # Decidable here: every closure target exists by the time this walk runs.
+    if(_item MATCHES "^\\$<TARGET_NAME_IF_EXISTS:([^>]+)>$")
+      if(TARGET "${CMAKE_MATCH_1}")
+        list(APPEND _worklist "${CMAKE_MATCH_1}")
+      endif()
+      continue()
+    endif()
+    if(_item MATCHES "^\\$<IF:\\$<TARGET_EXISTS:([^>]+)>,([^,]*),([^>]*)>$")
+      if(TARGET "${CMAKE_MATCH_1}")
+        set(_resolved "${CMAKE_MATCH_2}")
+      else()
+        set(_resolved "${CMAKE_MATCH_3}")
+      endif()
+      if(_resolved)
+        list(APPEND _worklist "${_resolved}")
+      endif()
+      continue()
+    endif()
+    # iree_cc_library reaches usage requirements through this indirection.
+    if(_item MATCHES "^\\$<TARGET_PROPERTY:([^,>]+),([A-Za-z_]+)>$")
+      if(TARGET "${CMAKE_MATCH_1}")
+        get_target_property(_prop_value "${CMAKE_MATCH_1}" "${CMAKE_MATCH_2}")
+        if(_prop_value)
+          list(APPEND _worklist ${_prop_value})
+        endif()
+      endif()
+      continue()
+    endif()
+    # The compiler dylib is no MLIR aggregate, so this exclusion list is empty
+    # and the guard always admits the library.
+    if(_item MATCHES "^\\$<\\$<NOT:\\$<IN_LIST:.*MLIR_AGGREGATE_EXCLUDE_LIBS.*>:([A-Za-z0-9_]+)>$")
+      list(APPEND _worklist "${CMAKE_MATCH_1}")
+      continue()
+    endif()
+    # LLVM guards platform link flags this way. The probe is a literal by now,
+    # so decide it rather than warn; an empty one contributes nothing.
+    if(_item MATCHES "^\\$<\\$<BOOL:(.*)>:(.*)>$")
+      set(_bool_cond "${CMAKE_MATCH_1}")
+      set(_bool_value "${CMAKE_MATCH_2}")
+      iree_renamed_link_genex_bool("${_bool_cond}" _bool_true)
+      if(_bool_true)
+        list(APPEND _worklist "${_bool_value}")
+      endif()
+      continue()
+    endif()
+    if(_item MATCHES "^\\$<")
+      # An unevaluated one would corrupt the build files. Dropping it yields an
+      # undefined symbol instead, which can be diagnosed.
+      message(WARNING
+        "iree_collect_static_link_closure: dropping unsupported generator "
+        "expression link item: ${_item}")
+      continue()
+    endif()
+    if(NOT TARGET "${_item}")
+      list(APPEND _other "${_item}")
+      continue()
+    endif()
+    get_target_property(_aliased "${_item}" ALIASED_TARGET)
+    if(_aliased)
+      list(APPEND _worklist "${_aliased}")
+      continue()
+    endif()
+    get_target_property(_imported "${_item}" IMPORTED)
+    get_target_property(_type "${_item}" TYPE)
+    if(_imported)
+      list(APPEND _other "${_item}")
+    elseif(_type STREQUAL "STATIC_LIBRARY")
+      list(APPEND _static_libs "${_item}")
+    elseif(_type STREQUAL "INTERFACE_LIBRARY" OR _type STREQUAL "OBJECT_LIBRARY")
+      # Recurse only. Object libraries are the walk's seeds; their objects are
+      # archived and whole-archive linked by the caller, so linking the
+      # library target itself would duplicate them unrenamed.
+    else()
+      # Shared libraries, executables: pass through.
+      list(APPEND _other "${_item}")
+    endif()
+    foreach(_prop LINK_LIBRARIES INTERFACE_LINK_LIBRARIES)
+      get_target_property(_deps "${_item}" ${_prop})
+      if(_deps)
+        list(APPEND _worklist ${_deps})
+      endif()
+    endforeach()
+  endwhile()
+  set(${STATIC_LIBS_VAR} "${_static_libs}" PARENT_SCOPE)
+  set(${OTHER_VAR} "${_other}" PARENT_SCOPE)
+endfunction()
+
+# Assembles the renamed libIREECompiler link.
+#
+# Deferred to the end of the top-level directory so the closure walk sees every
+# target: the API directory is configured before most of the compiler tree, so
+# aliases like iree::compiler::Codegen::Common do not exist yet.
+#
+# Reads IREE_RENAMED_SHARED_IMPL_OBJECT_LIBS, set by compiler/src/iree/compiler/API.
+function(iree_renamed_shared_impl_finalize)
+  get_property(_object_libs GLOBAL PROPERTY IREE_RENAMED_SHARED_IMPL_OBJECT_LIBS)
+  set(_target iree_compiler_API_SharedImpl)
+
+  # Whole-archive: these are the exported surface, so nothing references them.
+  set(_whole_archives)
+  foreach(_object_lib ${_object_libs})
+    iree_renamed_link_sanitize_name("${_object_lib}" _base)
+    iree_renamed_archive_from_objects(_renamed
+      NAME "${_base}"
+      TARGET "${_object_lib}"
+    )
+    list(APPEND _whole_archives "${_renamed}")
+  endforeach()
+
+  # The closure is renamed too, or the references above find nothing. It keeps
+  # selective extraction, so the same objects land as in an unrenamed link.
+  iree_collect_static_link_closure(_closure_static_libs _closure_other
+    ${_object_libs}
+    MLIRExportSMTLIB
+    MLIRTargetLLVMIRImport
+  )
+  set(_closure_archives)
+  foreach(_lib ${_closure_static_libs})
+    iree_renamed_link_sanitize_name("${_lib}" _base)
+    iree_renamed_archive(_renamed
+      NAME "closure_${_base}"
+      INPUT "$<TARGET_FILE:${_lib}>"
+      DEPENDS "${_lib}"
+    )
+    list(APPEND _closure_archives "${_renamed}")
+  endforeach()
+
+  if(APPLE)
+    set(_whole_archive_flags)
+    foreach(_archive ${_whole_archives})
+      list(APPEND _whole_archive_flags "-Wl,-force_load,${_archive}")
+    endforeach()
+    # ld64 ignores command-line order.
+    target_link_options(${_target} PRIVATE ${_whole_archive_flags})
+    target_link_libraries(${_target} PRIVATE
+      ${_closure_archives}
+      ${_closure_other}
+    )
+  else()
+    # Grouped so single-pass ELF linkers do not care about archive order.
+    target_link_libraries(${_target} PRIVATE
+      "-Wl,--whole-archive" ${_whole_archives} "-Wl,--no-whole-archive"
+      "-Wl,--start-group" ${_closure_archives} "-Wl,--end-group"
+      ${_closure_other}
+    )
+  endif()
+
+  # Orders archive generation before the link.
+  add_custom_target(${_target}_renamed_archives
+    DEPENDS ${_whole_archives} ${_closure_archives}
+  )
+  add_dependencies(${_target} ${_target}_renamed_archives)
+  set_property(TARGET ${_target} APPEND PROPERTY
+    LINK_DEPENDS ${_whole_archives} ${_closure_archives}
+  )
+endfunction()
diff --git a/compiler/CMakeLists.txt b/compiler/CMakeLists.txt
index 79dae71..024d0d5 100644
--- a/compiler/CMakeLists.txt
+++ b/compiler/CMakeLists.txt
@@ -21,6 +21,13 @@
   set(IREE_COMPILER_DYLIB_INSTALL_PREFIX "lib/")
 endif()
 
+# Plugins resolve compiler symbols by name, and hidden visibility would strip
+# them before the linker sees them, beyond recovery. Scoped here so the runtime
+# keeps hidden visibility.
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+  list(REMOVE_ITEM IREE_DEFAULT_COPTS "-fvisibility=hidden")
+endif()
+
 # Always build the C bindings, since the API is available apart from
 # actually building the compiler.
 add_subdirectory(bindings/c)
@@ -43,6 +50,11 @@
     PLUGIN_CMAKE_FILE
       "iree_compiler_plugin.cmake"
   )
+  if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+    message(STATUS "Dynamically loaded compiler plugins enabled (renamed LLVM/MLIR ABI)")
+  else()
+    message(STATUS "Dynamically loaded compiler plugins disabled (IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=OFF)")
+  endif()
   add_subdirectory(src)
 
   # Reset BUILD_SHARED_LIBS.
diff --git a/compiler/bindings/c/BUILD.bazel b/compiler/bindings/c/BUILD.bazel
index ad4abd4..81943bb 100644
--- a/compiler/bindings/c/BUILD.bazel
+++ b/compiler/bindings/c/BUILD.bazel
@@ -47,10 +47,10 @@
         "iree/compiler/loader/loader_test.c",
     ],
     args = [
-        "lib/libIREECompiler.so",
+        "$(location //lib:IREECompiler)",
     ],
     data = [
-        "//lib:libIREECompiler.so",
+        "//lib:IREECompiler",
     ],
     tags = ["skip-bazel_to_cmake"],
     deps = [
diff --git a/compiler/src/iree/compiler/API/BUILD.bazel b/compiler/src/iree/compiler/API/BUILD.bazel
index f10c922..59fd351 100644
--- a/compiler/src/iree/compiler/API/BUILD.bazel
+++ b/compiler/src/iree/compiler/API/BUILD.bazel
@@ -17,7 +17,7 @@
     "api_exports.c",
     "api_exports.def",
     "api_exports.ld",
-    "api_exports.lst",
+    "api_exports.macos.lst",
 ])
 
 # IMPORTANT: If modifying the list of dependencies, mirror the changes
@@ -56,9 +56,7 @@
 
 iree_compiler_cc_library(
     name = "SharedImpl",
-    srcs = [
-        "//lib:libIREECompiler.so",
-    ],
+    srcs = ["//lib:IREECompiler"],
     tags = ["skip-bazel_to_cmake"],
 )
 
diff --git a/compiler/src/iree/compiler/API/CMakeLists.txt b/compiler/src/iree/compiler/API/CMakeLists.txt
index d0a0a69..af3edc9 100644
--- a/compiler/src/iree/compiler/API/CMakeLists.txt
+++ b/compiler/src/iree/compiler/API/CMakeLists.txt
@@ -160,6 +160,29 @@
 # library structures.
 set(CMAKE_PLATFORM_NO_VERSIONED_SONAME ON)
 
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+  # Renamed ABI (see build_tools/cmake/iree_renamed_link.cmake). Exports
+  # everything but the runtime's `iree_*` C-API, so plugins renamed the same way
+  # resolve against this library at dlopen.
+  set(_SHARED_IMPL_LINKOPTS
+    "$<$<PLATFORM_ID:Linux>:-Wl,--version-script=${IREE_ROOT_DIR}/lib/hide_iree_runtime_capi.ld>"
+    "$<$<PLATFORM_ID:Darwin>:-Wl,-unexported_symbols_list,${IREE_ROOT_DIR}/lib/hide_iree_runtime_capi.macos.txt>"
+  )
+  set(_SHARED_IMPL_LINK_DEPENDS
+    "${IREE_ROOT_DIR}/lib/hide_iree_runtime_capi.ld;${IREE_ROOT_DIR}/lib/hide_iree_runtime_capi.macos.txt"
+  )
+else()
+  set(_SHARED_IMPL_LINKOPTS
+    # Linux uses a linker script to version symbols.
+    "$<$<PLATFORM_ID:Linux>:-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/api_version.ld>"
+    # Hide all symbols from static archives (.a) linked into the shared library.
+    # Without this, LLVM/MLIR internal symbols leak through the `global: *`
+    # version script since LLVM is not compiled with -fvisibility=hidden.
+    "$<$<PLATFORM_ID:Linux>:LINKER:--exclude-libs,ALL>"
+  )
+  set(_SHARED_IMPL_LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/api_version.ld")
+endif()
+
 iree_cc_library(
   SHARED
   NAME
@@ -168,40 +191,54 @@
      # Force this to be a regular library with an empty source file.
      empty.c
   LINKOPTS
-    # Linux uses a linker script to version symbols.
-    $<$<PLATFORM_ID:Linux>:-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/api_version.ld>
-    # Hide all symbols from static archives (.a) linked into the shared library.
-    # Without this, LLVM/MLIR internal symbols leak through the `global: *`
-    # version script since LLVM is not compiled with -fvisibility=hidden.
-    $<$<PLATFORM_ID:Linux>:LINKER:--exclude-libs,ALL>
+    ${_SHARED_IMPL_LINKOPTS}
   DEPS
     iree::compiler::bindings::c::headers
 )
 
 # Make sure that re-link if extra linker inputs change.
 set_target_properties(iree_compiler_API_SharedImpl PROPERTIES
-  LINK_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/api_version.ld
+  LINK_DEPENDS "${_SHARED_IMPL_LINK_DEPENDS}"
 )
 
-# We need to add the object sources directly to the SHARED sub-target, not
-# the .objects sub-target, which is what would happen if added to SRCS.
-target_sources(iree_compiler_API_SharedImpl PRIVATE
-  ${_EXPORT_OBJECT_SRCS}
-)
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+  include(iree_renamed_link)
 
-# Explicitly link deps as PRIVATE (the default rule uses PUBLIC) so that usage
-# requirements do not cascade: The shared library is intended to be used as an
-# isolated boundary for C consumers.
-target_link_libraries(iree_compiler_API_SharedImpl PRIVATE
-  ${_EXPORT_OBJECT_DEPS}
-)
+  # The payload arrives as archive paths, so CMake sees only empty.c and would
+  # pick the C driver, which omits libstdc++. The branch below infers CXX from
+  # the sources it adds.
+  set_target_properties(iree_compiler_API_SharedImpl PROPERTIES
+    LINKER_LANGUAGE CXX
+  )
 
-# Link MLIRTargetLLVMIRImport and MLIRExportSMTLIB directly since they are
-# not exported as object libraries.
-target_link_libraries(iree_compiler_API_SharedImpl PRIVATE
-  MLIRExportSMTLIB
-  MLIRTargetLLVMIRImport
-)
+  # Deferred to the end of the top-level directory, where every target exists:
+  # the closure walk needs them, and most of the compiler tree is unconfigured
+  # while this directory runs. The branch below defers to generate time instead.
+  set_property(GLOBAL PROPERTY
+    IREE_RENAMED_SHARED_IMPL_OBJECT_LIBS "${_EXPORT_OBJECT_LIBS}")
+  cmake_language(DEFER DIRECTORY "${IREE_ROOT_DIR}"
+    CALL iree_renamed_shared_impl_finalize)
+else()
+  # We need to add the object sources directly to the SHARED sub-target, not
+  # the .objects sub-target, which is what would happen if added to SRCS.
+  target_sources(iree_compiler_API_SharedImpl PRIVATE
+    ${_EXPORT_OBJECT_SRCS}
+  )
+
+  # Explicitly link deps as PRIVATE (the default rule uses PUBLIC) so that usage
+  # requirements do not cascade: The shared library is intended to be used as an
+  # isolated boundary for C consumers.
+  target_link_libraries(iree_compiler_API_SharedImpl PRIVATE
+    ${_EXPORT_OBJECT_DEPS}
+  )
+
+  # Link MLIRTargetLLVMIRImport and MLIRExportSMTLIB directly since they are
+  # not exported as object libraries.
+  target_link_libraries(iree_compiler_API_SharedImpl PRIVATE
+    MLIRExportSMTLIB
+    MLIRTargetLLVMIRImport
+  )
+endif()
 
 # If not using sanitizers, ask linkers to error on undefined symbols.
 # For shared libraries, using sanitizers implies undefined symbols on Unix-like.
diff --git a/docs/website/docs/building-from-source/getting-started.md b/docs/website/docs/building-from-source/getting-started.md
index 525ad86..6479d20 100644
--- a/docs/website/docs/building-from-source/getting-started.md
+++ b/docs/website/docs/building-from-source/getting-started.md
@@ -136,6 +136,12 @@
         -DIREE_ENABLE_ASSERTIONS=ON
     ```
 
+!!! Note "Note - thin archives and dynamic compiler plugins"
+    `-DIREE_ENABLE_THIN_ARCHIVES=ON` cannot be combined with
+    `-DIREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=ON`; configuring both fails. Dynamic
+    plugins resolve against a renamed copy of the compiler's symbols, and the
+    rename cannot rewrite a thin archive.
+
 ???+ Tip "Tip - CMAKE_BUILD_TYPE values"
     We recommend using the `RelWithDebInfo` build type by default for a good
     balance of debug info and performance. The `Debug`, `Release`, and
diff --git a/docs/website/docs/developers/building/cmake-options.md b/docs/website/docs/developers/building/cmake-options.md
index 978097d..71e3992 100644
--- a/docs/website/docs/developers/building/cmake-options.md
+++ b/docs/website/docs/developers/building/cmake-options.md
@@ -136,6 +136,16 @@
 * `IREE_INPUT_TORCH`
 * `IREE_INPUT_TOSA`
 
+### `IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS`
+
+* 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`.
+
 ### `IREE_OUTPUT_FORMAT_C`
 
 * type: BOOL
diff --git a/lib/BUILD.bazel b/lib/BUILD.bazel
index 0a229e1..12b64aa 100644
--- a/lib/BUILD.bazel
+++ b/lib/BUILD.bazel
@@ -4,7 +4,13 @@
 # See https://llvm.org/LICENSE.txt for license information.
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
 
+load("//build_tools/bazel:abi_prefix.bzl", "IREE_COMPILER_ABI_PREFIX")
 load("//build_tools/bazel:build_defs.oss.bzl", "iree_compiler_cc_binary")
+load(
+    "//build_tools/bazel:renamed_link.bzl",
+    "iree_renamed_compiler_abi",
+    "renamed_cc_info",
+)
 
 package(
     default_visibility = ["//visibility:public"],
@@ -12,19 +18,82 @@
     licenses = ["notice"],  # Apache 2.0
 )
 
+# Hide the bundled runtime's `iree_*` C-API from the compiler library's
+# exports. See IREECompiler below for why.
+exports_files([
+    "hide_iree_runtime_capi.ld",
+    "hide_iree_runtime_capi.macos.txt",
+])
+
+# The IREE compiler shared library. Named "IREECompiler" because Bazel prefixes
+# "lib" to cc_binary(linkshared=1) outputs.
+#
+# Every llvm/mlir C++ symbol in the StaticImpl closure is renamed before link
+# (`_ZN4mlir...` -> `_ZN6IREE184mlir...`), so the library can share a process
+# with a foreign LLVM/MLIR without colliding, or weak-coalescing on macOS. The
+# renamed internals are exported for dynamic plugins.
+#
+# The runtime's `extern "C"` C-API is hidden by export list instead, the rename
+# reaching only mangled C++ names. Neither the compiler C-API (`ireeCompiler*`)
+# nor the renamed internals match `iree_*`, so nothing else is caught.
 iree_compiler_cc_binary(
-    name = "libIREECompiler.so",
+    name = "IREECompiler",
     srcs = [
         "//compiler/src/iree/compiler/API:api_exports.c",
     ],
-    linkopts = [
-        "-Wl,--version-script=$(location //compiler/src/iree/compiler/API:api_exports.ld)",
-        "-Wl,--no-undefined",
-    ],
+    additional_linker_inputs = select({
+        "//conditions:default": [":hide_iree_runtime_capi.ld"],
+        "@platforms//os:macos": [":hide_iree_runtime_capi.macos.txt"],
+    }),
+    linkopts = select({
+        "//conditions:default": [
+            "-Wl,--no-undefined",
+            "-Wl,--version-script=$(location :hide_iree_runtime_capi.ld)",
+        ],
+        "@platforms//os:macos": [
+            "-Wl,-install_name,@rpath/libIREECompiler.dylib",
+            "-Wl,-unexported_symbols_list,$(location :hide_iree_runtime_capi.macos.txt)",
+        ],
+    }),
     linkshared = 1,
+    tags = ["skip-bazel_to_cmake"],
     deps = [
+        ":IREEStaticImplRenamed",
         "//compiler/bindings/c:headers",
-        "//compiler/src/iree/compiler/API:StaticImpl",
-        "//compiler/src/iree/compiler/API:api_exports.ld",
     ],
 )
+
+# Renaming the CcInfo leaves the dylib the linker-input shape a normal Bazel
+# C++ link would see.
+renamed_cc_info(
+    name = "IREEStaticImplRenamed",
+    include_provided = True,
+    tags = ["skip-bazel_to_cmake"],
+    deps = ["//compiler/src/iree/compiler/API:StaticImpl"],
+)
+
+# Entry point for out-of-tree plugins: the library plus the prefix they must
+# rename to.
+iree_renamed_compiler_abi(
+    name = "IREECompilerShared",
+    shared_library = ":IREECompiler",
+    static_closure = "//compiler/src/iree/compiler/API:StaticImpl",
+    symbol_prefix = IREE_COMPILER_ABI_PREFIX,
+    tags = ["skip-bazel_to_cmake"],
+)
+
+# Guards the export invariants the linker scripts above rely on.
+py_test(
+    name = "exported_symbols_test",
+    srcs = ["exported_symbols_test.py"],
+    args = [
+        "--nm=$(location @llvm-project//llvm:llvm-nm)",
+        "--library=$(location :IREECompiler)",
+        "--symbol-prefix=" + IREE_COMPILER_ABI_PREFIX,
+    ],
+    data = [
+        ":IREECompiler",
+        "@llvm-project//llvm:llvm-nm",
+    ],
+    tags = ["skip-bazel_to_cmake"],
+)
diff --git a/lib/exported_symbols_test.py b/lib/exported_symbols_test.py
new file mode 100644
index 0000000..54f9189
--- /dev/null
+++ b/lib/exported_symbols_test.py
@@ -0,0 +1,103 @@
+#!/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
+"""Export invariants of libIREECompiler.
+
+The single renamed compiler library relies on naming conventions to keep three
+symbol families apart:
+
+  1. snake_case `iree_*`  -- the bundled runtime C-API; must be hidden so it
+     can never shadow a co-resident iree.runtime.
+  2. camelCase `ireeCompiler*` -- the compiler embedding C-API; must stay
+     exported.
+  3. the renamed llvm/mlir C++ internals, carrying the ABI prefix given by
+     --symbol-prefix; used by dynamic plugins and must stay exported.
+
+A convention violation (a snake_case export sneaking into the compiler, or the
+hide-script over-matching) silently binds the wrong symbol at load time, so
+this test turns it into a CI failure instead.
+
+Extension point: a co-installed runtime library can be added to `data` and
+checked for an empty exported-name intersection with the compiler library.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+import unittest
+
+NM: str = ""
+LIBRARY: str = ""
+SYMBOL_PREFIX: str = ""
+
+# Mach-O adds a leading underscore to C-level names.
+_RUNTIME_CAPI_RE = re.compile(r"^_?iree_[a-z0-9_]+$")
+_COMPILER_CAPI_RE = re.compile(r"^_?ireeCompiler[A-Za-z0-9]+$")
+# Built from the prefix, whose length is part of the mangled component.
+_RENAMED_CXX_RE: re.Pattern[str] | None = None
+
+
+def _exported_symbols() -> list[str]:
+    result = subprocess.run(
+        [NM, "--extern-only", "--defined-only", "--no-demangle", LIBRARY],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+        check=True,
+    )
+    symbols = []
+    for line in result.stdout.splitlines():
+        fields = line.split()
+        if len(fields) >= 2 and fields[-2] not in ("U",):
+            symbols.append(fields[-1])
+    return symbols
+
+
+class ExportedSymbolsTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.symbols = _exported_symbols()
+
+    def test_runtime_capi_is_hidden(self):
+        leaked = [s for s in self.symbols if _RUNTIME_CAPI_RE.match(s)]
+        self.assertEqual(
+            leaked[:20],
+            [],
+            msg="snake_case iree_* runtime C-API symbols leaked into the "
+            "compiler export table; hide_iree_runtime_capi.* must cover them",
+        )
+
+    def test_compiler_capi_is_exported(self):
+        self.assertTrue(
+            any(_COMPILER_CAPI_RE.match(s) for s in self.symbols),
+            msg="no ireeCompiler* embedding C-API symbol exported",
+        )
+
+    def test_renamed_internals_are_exported(self):
+        self.assertTrue(
+            any(_RENAMED_CXX_RE.match(s) for s in self.symbols),
+            msg=f"no renamed ({SYMBOL_PREFIX}) llvm/mlir C++ symbol exported; "
+            "the rename pipeline or export scripts are broken",
+        )
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--nm", required=True)
+    parser.add_argument("--library", required=True)
+    parser.add_argument("--symbol-prefix", required=True)
+    args, remaining = parser.parse_known_args()
+    NM = args.nm
+    LIBRARY = args.library
+    SYMBOL_PREFIX = args.symbol_prefix
+    _RENAMED_CXX_RE = re.compile(
+        r"^_?_Z(N|TVN|TTN|TIN|TSN|GVN)?%d%s"
+        % (len(SYMBOL_PREFIX), re.escape(SYMBOL_PREFIX))
+    )
+    unittest.main(argv=[sys.argv[0]] + remaining)
diff --git a/lib/hide_iree_runtime_capi.ld b/lib/hide_iree_runtime_capi.ld
new file mode 100644
index 0000000..92453b0
--- /dev/null
+++ b/lib/hide_iree_runtime_capi.ld
@@ -0,0 +1,16 @@
+# Version script for libIREECompiler (Linux/ELF).
+#
+# The compiler lib bundles the IREE runtime, so it also carries the runtime's
+# `iree_*` C-API under its real names, the rename reaching only mangled C++.
+# Compile-then-run flows load both libraries at once, and a runtime plugin's
+# undefined iree_hal_* may then bind to the compiler's copy, leaving its device
+# without the loaders the real runtime registered.
+#
+# So hide every snake_case `iree_*` from the export table. Nothing else resolves
+# here by that spelling: the embedding C-API is camelCase, the MLIR C-API is
+# mlir*, and the renamed internals carry the ABI prefix.
+# exported_symbols_test.py keeps it that way.
+{
+  global: *;
+  local: iree_*;
+};
diff --git a/lib/hide_iree_runtime_capi.macos.txt b/lib/hide_iree_runtime_capi.macos.txt
new file mode 100644
index 0000000..845c8db
--- /dev/null
+++ b/lib/hide_iree_runtime_capi.macos.txt
@@ -0,0 +1,8 @@
+# -unexported_symbols_list for libIREECompiler (macOS/Mach-O).
+#
+# See hide_iree_runtime_capi.ld for why.
+#
+# Mach-O prefixes C names with an underscore, so the runtime C-API is `_iree_*`
+# here. The embedding C-API and renamed internals do not match, and stay
+# exported.
+_iree_*