Advanced CMake Topics
This section offers some more advanced CMake code for situations that may not fit all projects.
Passing CMake Option to C/C++ Compile Options
The previous article demonstrated how to set up CMake options and operate on them to add specific compilation flags to the building process. It is also possible to use the same setup to provide definitions to be passed to the compiler as the source code is built using target_compile_definitions() for a whole target, or add_compile_definitions() to do it for just a specific CMakeLists.txt file. Another function, add_definitions(), is also available, but is more of an overkill/outdated approach.
option(MY_BUILD_OPTION "Enable specific build option" OFF)
if(MY_BUILD_OPTION)
target_compile_definitions(my_library PRIVATE ENABLE_BUILD_A)
endif()
Then, the
-DENABLE_BUILD_A will be added to the compilation, allowing for C++ code like this:void some_function()
{
#ifdef ENABLE_BUILD_A
/* Special code to do in this specific build */
#endif
}
One last point to keep in mind: be careful when using any #ifdef inside the public header(s) that will be installed alongside the library (private internal headers will not have this issue). If the CMake module for that library does not properly re-add the definition (or code using the library is manually linking to it), downstream codes may end up using the public header without the definitions the library was built with!
Making a CMake Project Export a CMake Module
Note: The following section is less of "known good practice" and more of a home-baked solution that works for general needs. As such, it may not feature optimal CMake code.
Speaking of CMake modules, CMake does offer a way to export the files needed so that a downstream project can do find_package(my_library) and link to it the way any other library is (such as MPI). Unfortunately, this process is not as simple as single line, but is not too hard to "set it and forget it". Additionally, this article will try to cover the basics of what needs to change project-to-project and what each step generally does, but there are a lot of moving parts, and many ways to get everything setup to make CMake happy. For more details on exactly what this does, please see these links:
- The CMake package included in example
- Dependencies in CMake
- Documentation on what CMake looks for when looking for a package
- 3rd party example similar to code below
Generally, the project will need to have a series of function calls similar to the following CMake examples. Note that most of the following code utilizes ${PROJECT_NAME} name to provide a programmatic way to name the files.
Step 1: Setting up the Package
First, we need to tweak the install command a bit:
install(TARGETS my_library
EXPORT my_libraryTargets)
This tweak will cause the
install command to also export all of the information about what was installed to go into a new target called "my_libraryTargets", which will be used below to help prepare our CMake package. Additionally, this command will cause additional CMake files to be installed alongside your project. These files are key to allowing your package to properly link with its necessary libraries when being used downstream.
With that setup, we can be begin to prepare our package setup by first loading a CMake module that provides some functions that help create the necessary files:
# 1. Include library for helping configure package config files
include(CMakePackageConfigHelpers)
Step 2: Preparing the Configuration File
The configuration file is a file that is provided by the project (meaning it has a true file that can be tracked with source control) that will have some additional CMake code prepended once installed. These additional lines are automatically added so that a project does not need to manually set hard-coded path values for downstream use.
# 2. Create the configuration file
configure_package_config_file(
${CMAKE_CURRENT_SOURCE_DIR}/cmake/my_library-config.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake
INSTALL_DESTINATION share/${PROJECT_NAME}
)
Each of the three parameters are simple, but key to setting up the package files:
First Parameter, my_library-config.cmake.in:
The first is telling CMake where the input file is; this is the file each project must provide on their own, and provide the path to. In this example, ${CMAKE_CURRENT_SOURCE_DIR}/ resolves to the top level CMakeLists.txt, so that would mean the full file path of the file is <my_library>/cmake/my_library-config.cmake.in. The input file must have the following lines, at a minimum:
# Various CMake boilerplate code filled in by "configure_package_config_file"
@PACKAGE_INIT@
# Make sure that your library actually gets its dependencies included!
include("${CMAKE_CURRENT_LIST_DIR}/my_libraryTargets.cmake")
The first command uses the same syntax used by CMake in the
configure_file command (see documentation here), but with a variable specific to the module included in step one. After this line, a project can add additional CMake code to be run when a downstream project attempts to load this package. For example, because this file is technically passed through a configure_file CMake command, one can have the file setup in such a way that it passes along configuration information to downstream projects:@PACKAGE_INIT@
# Could do something like this so downstream projects can use this variable to check
set(my_library_ENABLE_BUILD_OPTION @MY_BUILD_OPTION@)
# Or, put explicit requirements in the file:
find_dependency(MPI REQUIRED)
The second command tells any project that is using our package about the location of the CMake code that describes what CMake targets our library offers. Without this line, you will likely get naming issues when trying to use any exported targets in your downstream library. The file referenced here is one that is generated while building your project as a result of the
INSTALL( ... EXPORT ...) command in Step 1. You should see as an installed file when you install your CMake project, and so you do not need to build this file yourself. The CMake variable used here tells CMake that the file it needs to process is in the same directory as where it is processing the file (see the output of Step 6 to see how CMake installs these files in the same location).
Note
The resulting file from configure_package_config_file will be processed by a downstream CMake project, and the only action happening on your initial input file when building your project is a simple "find and replace". As such, you should take care to write code as if it will be processed by another project. For example, if you do:
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
# something
endif()
Be sure to remember that the value for
CMAKE_BUILD_TYPE will be what the downstream project is using, NOT what you built your project with!As an another example, the use of
${CMAKE_CURRENT_LIST_DIR} in the -config.cmake.in file is not processed (or replaced) by the configure_package_config_file command. Instead, this is the part of the code we want to run when another CMake project attempts to load our packaged. When our package is loaded, the variable will be set to the current directory of the file (where it is installed to).
Second Parameter, ${PROJECT_NAME}Config.cmake:
This line is describing to CMake where it should store the resulting file before installation. The use of ${CMAKE_CURRENT_BINARY_DIR} allows the project to store the file in the build folder so that it can easily cleaned up while not being tracked by source control.
Final Parameters, INSTALL_DESTINATION share/${PROJECT_NAME}:
The last line is simply telling the command where the file will end up installed to. In this case, into a share/my_library, which is a file path CMake will look for when trying to find package helper files. A downstream project will still need to set -DCMAKE_PREFIX_PATH to include this directory in order to find the library.
Step 3: The Version File
The next step is creating a file that tells downstream CMake the details about the version of this package, and its compatibility rules with other versions a downstream package might request. For example, the following example requires that if downstream projects request a specific version, it must match the "X" and "Y" from a packages version "X.Y.Z". For more details, see the documentation on this function here.
# 3. Create rules describing the version of the config file
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config-version.cmake
COMPATIBILITY SameMinorVersion
)
The first parameter in this function is telling where the file should be stored before installation, which is set to a location in the build folder, as done in Step 2.
Step 4: Installing the Package Files
Now that the project has created a file for CMake to know the rules for loading and the rules on versioning, they can be installed with the install command and the FILES option:
# 4. Specifically install the CMake package files
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config-version.cmake
${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake
DESTINATION share/${PROJECT_NAME}
)
The paths provided above are the paths to the location of where the temporary files made in Steps 2 and 3 where stored. The paths here should match the output paths provided in those step. An with the previous install command, these files should also be installed into the
share/my_library folder.
Step 5: Creating Downstream Targets
This last step tells CMake to create one more file. This file is what CMake uses to offer CMake targets (such as MPI::MPI_C or THREADS::THREADS) that the downstream project can use to link to. To do this, the install command is used with the target created in Step 1, alongside the NAMESPACE prefix to attach all targets installed by Step 1 into the stream-triggeringTargets target. In this example, that is only the my_library target, so the only target for use downstream would be the BAR::my_library target.
# 5. Install the project
install(EXPORT stream-triggeringTargets
NAMESPACE BAR::
DESTINATION share/${PROJECT_NAME}
)
Note that this command will install TWO files, and these files will be attached to a specific build type of your project. Therefore, it is highly advised that you make sure a default build type is set for your project if it exports a module:
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE RELEASE CACHE STRING "Build type for project." FORCE)
endif()
Step 6: Expected Output
If the CMake code was setup correctly, there should now be a few additional files installed when make install is run, with output similar to the following (MPIPCL is used as an example):
-- Installing: <install prefix>/share/MPIPCL/MPIPCLConfig-version.cmake
-- Installing: <install prefix>/mpipcl/share/MPIPCL/MPIPCLConfig.cmake
-- Installing: <install prefix>/mpipcl/share/MPIPCL/MPIPCLTargets.cmake
-- Installing: <install prefix>/mpipcl/share/MPIPCL/MPIPCLTargets-release.cmake
As a parting note, be sure to watch the capitalization of the names of the targets used here, as they directly influence the name of the downstream targets passed to find_package. CMake is case-sensitive when looking for packages infind_package(...)
Finding Custom Libraries
Occasionally, there may be library dependencies that do not provide the CMake files to make find_package(foo) possible. In this case, a project can build its own FindFOO.cmake file and tell CMake how to find it. This approach offers a clean way to abstract the necessary code out of the main CMakeLists.txt while still allowing the project to utilize find_package(foo)
In addition to the file mentioned above, there is one more line of code that needs to be added:
# Update the path that CMAKE will search for Find_XXX.cmake files
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
As the comment implies, this line adds to the list of locations CMake will use to look for
FindFOO when it needs to find packages from find_package. In the above command, it follows the principle of putting all CMake related files in a folder called cmake off the top level of the project, though this path is not required.
With this set, the project is now free to create a FindFOO.cmake file:
# Once done this will define
# FOO_FOUND - System has Foo
# FOO_INCLUDE_DIR - The Foo include directories
# FOO_LIBRARY - The libraries needed to use Foo
# search prefix path to use, defaults to CMAKE_PREFIX_PATH
set(FOO_PREFIX "${CMAKE_PREFIX_PATH}" CACHE STRING "Help cmake to find Foo library")
# check for include file existence
find_path(FOO_INCLUDE_DIR foo.h
HINTS ${FOO_PREFIX}/include)
# check library existence
find_library(FOO_LIBRARY NAMES foo
HINTS ${FOO_PREFIX}/lib)
# setup found
if (FOO_LIBRARY AND FOO_INCLUDE_DIR)
set(FOO_FOUND ON)
endif()
# handle QUIET/REQUIRED
include(FindPackageHandleStandardArgs)
# handle the QUIETLY and REQUIRED arguments and set FOO_FOUND to TRUE
# if all listed variables are TRUE
find_package_handle_standard_args(Foo DEFAULT_MSG FOO_LIBRARY FOO_INCLUDE_DIR)
# Hide internal variables
mark_as_advanced(FOO_INCLUDE_DIR FOO_LIBRARY FOO_FOUND)
Here, this file acts as a wrapper for series of
find_path and find_library commands that essentially check for all the necessary components of linking to a third party library. Note that any projects using this basic template should adjust the names of the library and headers to match what is expected. Users of this style will still be able to provide the path to the library inside CMAKE_PREFIX_PATH like any other package, but this will only be true if FOO_PREFIX is not set, and is defaulted to ${CMAKE_PREFIX_PATH} as show in the example above.
Unfortunately, this approach also causes another deviation from the fully supported find_package(foo): the project must manually link to the library and manually include the headers of said library. While not complicated lines of CMake, it is important to remember them when using this approach:
target_include_directories(my_library PRIVATE ${FOO_INCLUDE_DIR})
target_link_libraries(my_library ${FOO_LIBRARY})
And after these lines, the library will fully included in the build of the project. As with writing CMake package files, be sure to watch the capitalization of the names of the files involved and the names passed to
find_package.
CUDA Support in CMake
CUDA (and GPU support in general) in CMake has improved over the past few years, and should be similar to compiling normal C and C++ code. Note, if the CMake version is less than CMake 3.17, some of these features below may not work. As such, projects should strive to use newer CMake versions when possible when using GPU languages.
For files and projects that need only to link to the CUDA libraries to make CUDA function calls (and not compile kernels), there is a CMake package that can be used in a similar way to MPI:
# Find CUDA libraries
find_package(CUDAToolkit REQUIRED)
# Link with whatever parts are needed
target_link_libraries(my_library CUDA::toolkit CUDA::cudart CUDA::nvtx3)
For more details on available targets, please see the documentation for FindCUDAToolkit.
If a project needs to compile actual CUDA language files (.cu files), then the simplest way to get CMake to compile them is to add the CUDA language to the CMake project command:
project(FOO VERSION 1.0.2 LANGUAGES C CXX CUDA)
# Adding a CUDA source file
target_sources(my_library PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/my_source_file_with_cuda.cu"
)
If done correctly, the project should have output similar to the following:
# In config:
-- The CUDA compiler identification is NVIDIA 12.8.93
# In build:
[ %] Building CUDA object CMakeFiles/my_library.dir/source/my_source_file_with_cuda_kernel.cu.o
Fully Supporting an Optional GPU Build
Some projects may wish to be able to build with and without GPU support. However, based on the information presented so far, it seems like the project line must include the GPU language the library may need instead of listing exactly what it will need. Luckily, the project line does not have to be the first line the main CMakeLists.txt, allowing a project do something like this:
option(CUDA_BUILD "Build with CUDA support" OFF)
option(HIP_BUILD "Build with HIP support" OFF)
if(CUDA_BUILD)
project(FOO VERSION 1.0.3 LANGUAGES C CXX CUDA)
elseif(HIP_BUILD)
project(FOO VERSION 1.0.3 LANGUAGES C CXX HIP)
else()
project(FOO VERSION 1.0.3 LANGUAGES C CXX)
endif()
With this setup, a project can clearly describe the languages needed at the beginning of the file. If a project chooses not to use this approach, note that there is also an enable_language function (see documentation), though that function offers more chances to introduce build and compile bugs if not done properly.