[llvm] [CMake] Propagate generated-header prerequisites through link dependencies (PR #222341)

Mehdi Amini via llvm-commits llvm-commits at lists.llvm.org
Fri Sep 11 15:51:18 PDT 2026


https://github.com/joker-eph updated https://github.com/llvm/llvm-project/pull/222341

>From aecddabd2787d751051974c144ee81c43155bca2 Mon Sep 17 00:00:00 2001
From: Mehdi Amini <joker.eph at gmail.com>
Date: Wed, 9 Sep 2026 03:51:24 -0700
Subject: [PATCH] [CMake] Propagate generated-header prerequisites through link
 dependencies

The goal is to simplify and make more robust the handling of generated-header
(TableGen) dependencies. Today these dependencies are managed manually, which
is fragile: missing edges often surface only in parallel builds, and sometimes
in incremental builds.

Record each llvm_add_library DEPENDS list independently from the cumulative
LLVM_COMMON_DEPENDS value, and mark public TableGen targets as generated-header
prerequisites. A deferred traversal follows every direct non-INTERFACE link and
transitive link interface, resolves aliases, and conservatively extracts target
candidates from generator expressions.

Represent generated-header prerequisites with internal INTERFACE libraries.
Let CMake propagate their utility dependencies through the header graph, and
reuse each provider's interface to handle cycles without computing closures.
Disabled generator-expression arms may generate extra headers, but cannot
introduce a provider library or object cycle.

Attach header interfaces only to consumer implementation links so they do not
leak into exports. Do not link provider libraries directly to object libraries:
that approach was reverted after an ODR regression with LLVM_LINK_LLVM_DYLIB.

Test independent clean consumer builds, cyclic interfaces, excluded unmarked
generators, and build-tree and install exports.

Assisted-by: Codex
Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply at anthropic.com>
---
 llvm/cmake/modules/AddLLVM.cmake              | 202 ++++++++++++++----
 llvm/cmake/modules/LLVM-Build.cmake           |   3 +
 llvm/cmake/modules/TableGen.cmake             |   6 +
 .../CMake/generated-header-dependencies.test  | 131 ++++++++++++
 llvm/test/CMake/lit.local.cfg                 |   2 +
 llvm/test/lit.site.cfg.py.in                  |   2 +
 6 files changed, 305 insertions(+), 41 deletions(-)
 create mode 100644 llvm/test/CMake/generated-header-dependencies.test
 create mode 100644 llvm/test/CMake/lit.local.cfg

diff --git a/llvm/cmake/modules/AddLLVM.cmake b/llvm/cmake/modules/AddLLVM.cmake
index 3169756c0f28a..91fa3d9143aed 100644
--- a/llvm/cmake/modules/AddLLVM.cmake
+++ b/llvm/cmake/modules/AddLLVM.cmake
@@ -554,6 +554,146 @@ function(set_windows_version_resource_properties name resource_file)
                "RC_PRODUCT_VERSION=\"${ARG_VERSION_STRING}\"")
 endfunction(set_windows_version_resource_properties)
 
+# Resolve an alias to the target that carries its build metadata.
+function(_llvm_resolve_target_alias output target)
+  if(TARGET "${target}")
+    get_target_property(aliased_target "${target}" ALIASED_TARGET)
+    if(aliased_target)
+      set(target "${aliased_target}")
+    endif()
+  endif()
+  set(${output} "${target}" PARENT_SCOPE)
+endfunction()
+
+# Collect the targets named by a link item. Generator expressions cannot be
+# evaluated at configure time, so conservatively return every token that names
+# a target. This can add prerequisites from disabled expression arms, but those
+# prerequisites generate headers only and cannot introduce library cycles.
+function(_llvm_link_item_targets output item)
+  if(item MATCHES "\\$<")
+    string(REGEX MATCHALL "[A-Za-z0-9_.+-]+(::[A-Za-z0-9_.+-]+)*"
+      candidates "${item}")
+  else()
+    set(candidates "${item}")
+  endif()
+
+  set(result)
+  foreach(candidate ${candidates})
+    if(TARGET "${candidate}")
+      _llvm_resolve_target_alias(candidate "${candidate}")
+      list(APPEND result "${candidate}")
+    endif()
+  endforeach()
+  list(REMOVE_DUPLICATES result)
+  set(${output} ${result} PARENT_SCOPE)
+endfunction()
+
+# Represent a library's generated headers with a build-local interface target.
+# CMake follows the interface graph and its utility dependencies, including
+# cycles, without adding dependencies on the provider's objects or archive.
+function(_llvm_generated_header_target output provider)
+  set(${output} PARENT_SCOPE)
+  if(NOT TARGET "${provider}")
+    return()
+  endif()
+  _llvm_resolve_target_alias(provider "${provider}")
+  get_property(headers TARGET "${provider}" PROPERTY LLVM_GENERATED_HEADER_INTERFACE)
+  if(headers)
+    set(${output} "${headers}" PARENT_SCOPE)
+    return()
+  endif()
+
+  get_target_property(imported "${provider}" IMPORTED)
+  get_target_property(type "${provider}" TYPE)
+  if(imported OR NOT type MATCHES
+     "^(STATIC|SHARED|MODULE|OBJECT|INTERFACE|UNKNOWN)_LIBRARY$")
+    # Imported generated headers already exist. Non-library targets cannot
+    # publish a transitive link interface.
+    return()
+  endif()
+
+  set(headers "llvm.headers.${provider}")
+  add_library("${headers}" INTERFACE)
+  # Publish the target before following edges so cyclic references reuse it.
+  set_property(TARGET "${provider}" PROPERTY LLVM_GENERATED_HEADER_INTERFACE
+    "${headers}")
+  set(${output} "${headers}" PARENT_SCOPE)
+
+  get_property(dependencies TARGET "${provider}" PROPERTY LLVM_EXPLICIT_DEPENDS)
+  foreach(dependency ${dependencies})
+    if(TARGET "${dependency}")
+      get_target_property(is_generated_header "${dependency}"
+        LLVM_GENERATED_HEADER_TARGET)
+      if(is_generated_header)
+        add_dependencies("${headers}" "${dependency}")
+      endif()
+    endif()
+  endforeach()
+
+  # Only a provider's interface is transitive. PRIVATE implementation links
+  # have already contributed to the provider's own compilation ordering.
+  get_property(links TARGET "${provider}" PROPERTY INTERFACE_LINK_LIBRARIES)
+  foreach(item ${links})
+    _llvm_link_item_targets(link_targets "${item}")
+    foreach(link_target ${link_targets})
+      _llvm_generated_header_target(child_headers "${link_target}")
+      if(child_headers)
+        target_link_libraries("${headers}" INTERFACE "${child_headers}")
+      endif()
+    endforeach()
+  endforeach()
+endfunction()
+
+# Record direct link dependencies for deferred generated-header resolution.
+# The target is the compilation target (normally an object library), not a
+# logical link provider.
+function(_llvm_record_link_dependencies target)
+  if(NOT ARGN)
+    return()
+  endif()
+  set_property(TARGET "${target}" APPEND PROPERTY LLVM_RECORDED_LINK_LIBS
+    ${ARGN})
+  set_property(GLOBAL APPEND PROPERTY LLVM_RECORDED_LINK_CONSUMERS "${target}")
+  get_property(scheduled GLOBAL PROPERTY
+    LLVM_LINK_DEPENDENCIES_SCHEDULED)
+  if(NOT scheduled)
+    cmake_language(DEFER DIRECTORY "${CMAKE_SOURCE_DIR}"
+      CALL _llvm_resolve_link_dependencies)
+    set_property(GLOBAL PROPERTY LLVM_LINK_DEPENDENCIES_SCHEDULED TRUE)
+  endif()
+endfunction()
+
+# Order compilation after generated-header utility targets reachable through
+# direct PUBLIC, PRIVATE, or unqualified links and transitive interfaces. Do
+# not depend on the provider libraries themselves: that would create strong
+# cycles for mutually linked static libraries and serialize compilation behind
+# archive creation on generators with strong target ordering.
+function(_llvm_resolve_link_dependencies)
+  get_property(consumers GLOBAL PROPERTY LLVM_RECORDED_LINK_CONSUMERS)
+  list(REMOVE_DUPLICATES consumers)
+  foreach(consumer ${consumers})
+    if(NOT TARGET "${consumer}")
+      continue()
+    endif()
+    get_target_property(items "${consumer}" LLVM_RECORDED_LINK_LIBS)
+    set(generated_headers)
+    foreach(item ${items})
+      _llvm_link_item_targets(providers "${item}")
+      foreach(provider ${providers})
+        _llvm_generated_header_target(provider_headers "${provider}")
+        list(APPEND generated_headers ${provider_headers})
+      endforeach()
+    endforeach()
+    list(REMOVE_DUPLICATES generated_headers)
+    if(generated_headers)
+      # These interface targets carry only build ordering. Keep them out of
+      # INTERFACE_LINK_LIBRARIES, including exported static/object interfaces.
+      set_property(TARGET "${consumer}" APPEND PROPERTY LINK_LIBRARIES
+        ${generated_headers})
+    endif()
+  endforeach()
+endfunction()
+
 # llvm_add_library(name sources...
 #   SHARED;STATIC
 #     STATIC by default w/o BUILD_SHARED_LIBS.
@@ -674,25 +814,27 @@ function(llvm_add_library name)
     if(ARG_DEPENDS)
       add_dependencies(${obj_name} ${ARG_DEPENDS})
     endif()
-    # Treat link libraries like PUBLIC dependencies.  LINK_LIBS might
-    # result in generating header files.  Add a dependendency so that
-    # the generated header is created before this object library.
-    if(ARG_LINK_LIBS)
+    # Record direct compilation dependencies after all targets have been
+    # declared. INTERFACE entries are not used by this target's sources.
+    if(ARG_LINK_LIBS OR LLVM_LINK_COMPONENTS)
       cmake_parse_arguments(LINK_LIBS_ARG
         ""
         ""
-        "PUBLIC;PRIVATE"
+        "PUBLIC;PRIVATE;INTERFACE"
         ${ARG_LINK_LIBS})
-      foreach(link_lib ${LINK_LIBS_ARG_PUBLIC})
-        if(LLVM_PTHREAD_LIB)
-          # Can't specify a dependence on -lpthread
-          if(NOT ${link_lib} STREQUAL ${LLVM_PTHREAD_LIB})
-            add_dependencies(${obj_name} ${link_lib})
-          endif()
-        else()
-          add_dependencies(${obj_name} ${link_lib})
-        endif()
-      endforeach()
+      # Component libraries resolve their links in
+      # LLVMBuildResolveComponentsLink, after all components are registered.
+      # Record those canonical names there, including forward references.
+      set(link_component_libs)
+      if(NOT ARG_COMPONENT_LIB)
+        llvm_map_components_to_libnames(link_component_libs
+          ${LLVM_LINK_COMPONENTS})
+      endif()
+      _llvm_record_link_dependencies(${obj_name}
+        ${LINK_LIBS_ARG_PUBLIC}
+        ${LINK_LIBS_ARG_PRIVATE}
+        ${LINK_LIBS_ARG_UNPARSED_ARGUMENTS}
+        ${link_component_libs})
     endif()
 
     if(ARG_DISABLE_LLVM_LINK_LLVM_DYLIB)
@@ -731,6 +873,10 @@ function(llvm_add_library name)
   else()
     add_library(${name} STATIC ${ALL_FILES})
   endif()
+  # Preserve the exact DEPENDS list for generated-header propagation. Do not
+  # use MANUALLY_ADDED_DEPENDENCIES here: it also contains cumulative and
+  # post-hoc utility dependencies unrelated to the library's public headers.
+  set_property(TARGET ${name} PROPERTY LLVM_EXPLICIT_DEPENDS ${ARG_DEPENDS})
   set_target_properties(${name} PROPERTIES FOLDER "${subproject_title}/Libraries")
 
   ## If were compiling with clang-cl use /Zc:dllexportInlines- to exclude inline
@@ -1754,32 +1900,6 @@ function(canonicalize_tool_name name output)
   set(${output} "${nameUPPER}" PARENT_SCOPE)
 endfunction(canonicalize_tool_name)
 
-# Collect the targets named by a link item. Generator expressions cannot be
-# evaluated at configure time, so conservatively collect tokens that name
-# targets. This handles the link expressions used by LLVM's CMake helpers,
-# including LINK_ONLY and BUILD_INTERFACE.
-function(_llvm_link_item_targets output item)
-  if(item MATCHES "\\$<")
-    string(REGEX MATCHALL "[A-Za-z_][A-Za-z0-9_.+-]*(::[A-Za-z0-9_.+-]+)*"
-      candidates "${item}")
-  else()
-    set(candidates "${item}")
-  endif()
-
-  set(result)
-  foreach(candidate ${candidates})
-    if(TARGET "${candidate}")
-      get_target_property(aliased_target "${candidate}" ALIASED_TARGET)
-      if(aliased_target)
-        set(candidate "${aliased_target}")
-      endif()
-      list(APPEND result "${candidate}")
-    endif()
-  endforeach()
-  list(REMOVE_DUPLICATES result)
-  set(${output} ${result} PARENT_SCOPE)
-endfunction()
-
 # Install only the dependency-project targets reachable from a project's
 # exported targets. Add them to the consuming project's exports and use its
 # distribution policy. CMake follows their existing build dependencies, even
diff --git a/llvm/cmake/modules/LLVM-Build.cmake b/llvm/cmake/modules/LLVM-Build.cmake
index 525186cbbb19b..5b28fe21c2926 100644
--- a/llvm/cmake/modules/LLVM-Build.cmake
+++ b/llvm/cmake/modules/LLVM-Build.cmake
@@ -99,6 +99,9 @@ function(LLVMBuildResolveComponentsLink)
     if(llvm_libs)
       get_property(libtype TARGET ${llvm_component} PROPERTY LLVM_LIBTYPE)
       target_link_libraries(${llvm_component} ${libtype} ${llvm_libs})
+      if(TARGET obj.${llvm_component})
+        _llvm_record_link_dependencies(obj.${llvm_component} ${llvm_libs})
+      endif()
     endif()
   endforeach()
 endfunction()
diff --git a/llvm/cmake/modules/TableGen.cmake b/llvm/cmake/modules/TableGen.cmake
index 397089a2d3efe..fc70919b3e255 100644
--- a/llvm/cmake/modules/TableGen.cmake
+++ b/llvm/cmake/modules/TableGen.cmake
@@ -171,6 +171,12 @@ function(add_public_tablegen_target target)
   endif()
   add_custom_target(${target}
     DEPENDS ${TABLEGEN_OUTPUT})
+  # Libraries propagate only explicitly declared generated-header prerequisites
+  # through their link interfaces.  Keep this marker separate from the target
+  # type: other utility targets may generate sources or run build tools and are
+  # not safe compilation-order dependencies for arbitrary consumers.
+  set_target_properties(${target} PROPERTIES
+    LLVM_GENERATED_HEADER_TARGET TRUE)
   if(LLVM_COMMON_DEPENDS)
     add_dependencies(${target} ${LLVM_COMMON_DEPENDS})
   endif()
diff --git a/llvm/test/CMake/generated-header-dependencies.test b/llvm/test/CMake/generated-header-dependencies.test
new file mode 100644
index 0000000000000..30f2c402d15aa
--- /dev/null
+++ b/llvm/test/CMake/generated-header-dependencies.test
@@ -0,0 +1,131 @@
+# RUN: rm -rf %t
+# RUN: split-file %s %t
+# RUN: "%cmake_exe" -S %t -B %t/build -G "%cmake_generator" \
+# RUN:   -DLLVM_SOURCE_DIR="%llvm_src_root" 2>&1 | FileCheck %s
+# RUN: "%cmake_exe" --build %t/build --target Consumer --parallel 4
+# RUN: "%cmake_exe" --build %t/build --target clean
+# RUN: "%cmake_exe" --build %t/build --target OtherCycleConsumer --parallel 4
+# RUN: "%cmake_exe" --build %t/build --target clean
+# RUN: "%cmake_exe" --build %t/build --target ComponentConsumer --parallel 4
+# RUN: "%cmake_exe" --build %t/build --target clean
+# RUN: "%cmake_exe" --build %t/build --target obj.StaticConsumer --parallel 4
+# RUN: "%cmake_exe" --build %t/build --target clean
+# RUN: "%cmake_exe" --build %t/build --target obj.LLVMForwardConsumer --parallel 4
+
+# CHECK: -- Generated header dependency checks passed
+
+#--- CMakeLists.txt
+cmake_minimum_required(VERSION 3.20)
+project(GeneratedHeaderDependencies LANGUAGES C CXX)
+set(CMAKE_INSTALL_LIBDIR lib)
+list(APPEND CMAKE_MODULE_PATH "${LLVM_SOURCE_DIR}/cmake/modules")
+include(AddLLVM)
+include(TableGen)
+include(LLVM-Build)
+
+foreach(provider Base Provider 3rdParty Disabled CycleA CycleB LLVMProvider)
+  set(LLVM_COMMON_DEPENDS)
+  set(header "${CMAKE_CURRENT_BINARY_DIR}/${provider}.h")
+  add_custom_command(OUTPUT "${header}"
+    COMMAND ${CMAKE_COMMAND} -E touch "${header}")
+  set(TABLEGEN_OUTPUT "${header}")
+  add_public_tablegen_target(${provider}IncGen)
+  add_library(${provider} INTERFACE)
+  set_property(TARGET ${provider} PROPERTY
+    LLVM_EXPLICIT_DEPENDS ${provider}IncGen)
+endforeach()
+set(LLVM_COMMON_DEPENDS)
+add_custom_target(SourceGenerator
+  COMMAND ${CMAKE_COMMAND} -E false)
+set_property(TARGET Provider APPEND PROPERTY LLVM_EXPLICIT_DEPENDS
+  SourceGenerator)
+target_link_libraries(Provider INTERFACE Base)
+add_library(ProviderAlias ALIAS Provider)
+target_link_libraries(CycleA INTERFACE CycleB)
+target_link_libraries(CycleB INTERFACE CycleA)
+
+add_library(Consumer OBJECT consumer.cpp)
+target_include_directories(Consumer PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
+_llvm_record_link_dependencies(Consumer ProviderAlias
+  "$<$<BOOL:1>:3rdParty>" "$<$<BOOL:0>:Disabled>" CycleA
+  /absolute/libunused.a -lpthread)
+add_library(OtherCycleConsumer OBJECT cycle.cpp)
+target_include_directories(OtherCycleConsumer PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
+_llvm_record_link_dependencies(OtherCycleConsumer CycleB)
+
+# LINK_COMPONENTS is mapped to the same concrete libraries as LINK_LIBS, but
+# mapping happens after object-library creation. Its generated headers still
+# need to be ready before that object library compiles.
+set(LLVM_AVAILABLE_LIBS LLVMProvider)
+llvm_add_library(ComponentConsumer OBJECT PARTIAL_SOURCES_INTENDED
+  component.cpp LINK_COMPONENTS Provider)
+target_include_directories(obj.ComponentConsumer PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
+
+# A combined shared/static provider must publish its own generators through
+# both variants. Do not build its objects or archive to satisfy this consumer.
+llvm_add_library(DualProvider SHARED STATIC PARTIAL_SOURCES_INTENDED
+  empty.cpp DEPENDS ProviderIncGen)
+llvm_add_library(StaticConsumer OBJECT PARTIAL_SOURCES_INTENDED
+  static.cpp LINK_LIBS DualProvider_static)
+target_include_directories(obj.StaticConsumer PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
+
+# Component names are case-insensitive and can refer to libraries registered
+# later. Record their canonical targets only after component link resolution.
+llvm_add_library(LLVMForwardConsumer OBJECT COMPONENT_LIB PARTIAL_SOURCES_INTENDED
+  forward.cpp LINK_COMPONENTS forwardprovider)
+target_include_directories(obj.LLVMForwardConsumer PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
+add_library(LLVMForwardProvider INTERFACE)
+set_property(TARGET LLVMForwardProvider PROPERTY LLVM_EXPLICIT_DEPENDS BaseIncGen)
+list(APPEND LLVM_AVAILABLE_LIBS LLVMForwardProvider)
+add_library(Engine INTERFACE)
+LLVMBuildResolveComponentsLink()
+
+# The build-local interfaces must not leak into exported consumers.
+export(TARGETS Consumer OtherCycleConsumer FILE consumers.cmake)
+install(TARGETS Consumer OtherCycleConsumer EXPORT consumers OBJECTS DESTINATION lib)
+install(EXPORT consumers DESTINATION lib/cmake)
+
+function(check_dependencies)
+  get_target_property(actual Consumer LINK_LIBRARIES)
+  set(expected llvm.headers.Provider llvm.headers.3rdParty
+    llvm.headers.Disabled llvm.headers.CycleA)
+  list(SORT actual)
+  list(SORT expected)
+  if(NOT "${actual}" STREQUAL "${expected}")
+    message(FATAL_ERROR "Expected '${expected}', got '${actual}'")
+  endif()
+  get_target_property(interface Consumer INTERFACE_LINK_LIBRARIES)
+  if(interface)
+    message(FATAL_ERROR "Build-local header targets escaped: '${interface}'")
+  endif()
+  get_target_property(component_links obj.ComponentConsumer LINK_LIBRARIES)
+  if(NOT "llvm.headers.LLVMProvider" IN_LIST component_links)
+    message(FATAL_ERROR "Component headers missing: '${component_links}'")
+  endif()
+  message(STATUS "Generated header dependency checks passed")
+endfunction()
+cmake_language(DEFER CALL check_dependencies)
+
+#--- consumer.cpp
+#include "Base.h"
+#include "Provider.h"
+#include "3rdParty.h"
+#include "Disabled.h"
+#include "CycleA.h"
+#include "CycleB.h"
+
+#--- cycle.cpp
+#include "CycleA.h"
+#include "CycleB.h"
+
+#--- component.cpp
+#include "LLVMProvider.h"
+
+#--- empty.cpp
+void empty() {}
+
+#--- static.cpp
+#include "Provider.h"
+
+#--- forward.cpp
+#include "Base.h"
diff --git a/llvm/test/CMake/lit.local.cfg b/llvm/test/CMake/lit.local.cfg
new file mode 100644
index 0000000000000..07cfb650c817c
--- /dev/null
+++ b/llvm/test/CMake/lit.local.cfg
@@ -0,0 +1,2 @@
+config.substitutions.append(("%cmake_exe", config.host_cmake))
+config.substitutions.append(("%cmake_generator", config.host_cmake_generator))
diff --git a/llvm/test/lit.site.cfg.py.in b/llvm/test/lit.site.cfg.py.in
index f72ab5a205edf..f5fcd92a7ad7c 100644
--- a/llvm/test/lit.site.cfg.py.in
+++ b/llvm/test/lit.site.cfg.py.in
@@ -33,6 +33,8 @@ config.llvm_bindings = "@LLVM_BINDINGS@".split(' ')
 config.target_os = "@HOST_OS@"
 config.host_cc = "@HOST_CC@"
 config.host_cxx = "@HOST_CXX@"
+config.host_cmake = "@CMAKE_COMMAND@"
+config.host_cmake_generator = "@CMAKE_GENERATOR@"
 # Note: ldflags can contain double-quoted paths, so must use single quotes here.
 config.host_ldflags = '@HOST_LDFLAGS@'
 config.llvm_use_intel_jitevents = @LLVM_USE_INTEL_JITEVENTS@



More information about the llvm-commits mailing list