Dependencies in cmake
Many C++ projects have external dependencies, but there is no easy way to ensure that the required dependencies are available at build time. There has been a lot of work on package managers such as conan and vcpkg, while other projects use git submodules or just throw an error if dependencies cannot be found. Many projects already use the build system generator cmake, which provides built in functionality to help solve this problem without requiring developers to add support for a package management system.
The primary way to find and control dependencies in cmake is through
find_package
, which looks for files matching a dependency and typically
creates targets encapsulating that dependency. The ExternalProject module can
be used alongside find_package
to automatically download and build any
dependencies that cannot be found locally on the system.
Find package support
Dependencies in cmake can be found using the find_package
command in your CMakeLists.txt files. There are a number of libraries that have
find modules shipped as part of cmake (a list can be found in the cmake
manual), so using these dependencies is easy. As an example, the
OpenCL library and headers can be found using:
find_package(OpenCL REQUIRED)
In recent versions of cmake this will provide an imported target
OpenCL::OpenCL
, as specified in FindOpenCL. This target will include the
library to link against and the include directories required to find the OpenCL
headers on the system. Then adding this dependency to an executable target is as
simple as specifying the OpenCL target as a link library:
find_package(OpenCL REQUIRED)
add_executable(my_executable ...)
target_link_libraries(my_executable PUBLIC OpenCL::OpenCL)
If cmake can find OpenCL in a system directory, then the executable target will be built with the OpenCL include directory correctly passed to the compiler and the OpenCL library linked into the executable. However it is possible that a user has OpenCL in a non-standard location, where cmake will not look by default. This user can provide the locations of the library and headers to cmake as additional options:
cmake -DOpenCL_INCLUDE_DIR=<path/to/headers> \
-DOpenCL_LIBRARY=<path/to/libOpenCL> \
<path/to/source>
Writing find modules
There are many libraries with find modules included with cmake, but what if you need a dependency that is not? You will need to provide a find module of your own. The cmake manual provides some assistance to help with this.
When you call find_package(...)
cmake will look for a find module
corresponding to the requested dependency. The find module has to have a name
matching Find<lib>.cmake
, where <lib>
is the library name that will be used
when calling find_package
(e.g. find_package(GTest)
looks for
FindGTest.cmake
). These find modules need to be available in the
CMAKE_MODULE_PATH
, so you might need to add the directory containing the
modules to this list in your CMakeLists.txt.
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/Modules/")
A typical find module will contain a call to find_path
which
looks for the libraries header files, and a call to
find_library
which looks for the library itself. Keeping with
the example of OpenCL, if we were to implement our own find module it would need
to include the following:
find_library(OpenCL_LIBRARY
NAMES OpenCL
)
find_path(OpenCL_INCLUDE_DIR
NAMES CL/cl.h
)
The OpenCL_LIBRARY
and OpenCL_INCLUDE_DIR
variables are the same as those
used above for a user to specify the location of the OpenCL library. If these
variables are not defined, then cmake will look for the OpenCL library and
headers. If these variables are already defined when find_*
is called then
cmake assumes that the path in the variable is correct and will not search
further.
However if cmake fails to find the library or headers, then the variables are
left undefined. Most find modules make use of the
FindPackageHandleStandardArgs
module to consistently handle cases like this.
To do this, the find module should include something like:
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(OpenCL
FOUND_VAR OpenCL_FOUND
REQUIRED_VARS OpenCL_LIBRARY OpenCL_INCLUDE_DIR
)
This tells cmake which variables have to be defined in order for the dependency
to be met and sets OpenCL_FOUND
to true
if it is available or false
if
not. If a user calls find_package(OpenCL REQUIRED)
and cmake cannot find the
library or headers then the find_package_handle_standard_args
function call
will output an error.
Now that we know whether or not the dependency has been found and we know the paths to the headers and the library this information needs to made available to users. This is best done by creating a new target that incorporates these imported paths:
if(OpenCL_FOUND AND NOT TARGET OpenCL::OpenCL)
add_library(OpenCL::OpenCL UNKNOWN IMPORTED)
set_target_properties(OpenCL::OpenCL PROPERTIES
IMPORTED_LOCATION "${OpenCL_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${OpenCL_INCLUDE_DIR}"
)
endif()
This imported target can be treated in just the same way as the OpenCL::OpenCL
target provided by cmake’s built in FindOpenCL module. In fact you can look at
the FindOpenCL source code to see that under the hood this is all that cmake
is doing (albeit with more platform specific workarounds).
Another full example is included in SYCL-DNN, which uses Google benchmark as its benchmarking framework. Its find module is very straight forward and closely follows the above.
Using ExternalProject
Find modules are really good for finding dependencies already installed on a system, but often a project has external dependencies that are unlikely to be installed and a user may not actually want to or be able to install them. In cases like this we can instruct cmake to download its own copy of the dependencies and use that instead.
The easiest way to do this is to use the ExternalProject cmake module. This
contains the ExternalProject_Add
function which will create a new target to
fetch and build an external dependency:
ExternalProject_Add(project_name
GIT_REPOSITORY https://github.com/path_to/git_repo.git
GIT_TAG master
)
When you run cmake a target called project_name
will be created which
will download, build and install the project. If the project uses cmake itself,
then the project’s cmake files will be used to build the project.
If the external project does not use cmake, then you will have to add the
commands required to build the project yourself in the ExternalProject_Add
function, using the BUILD_COMMAND
, INSTALL_COMMAND
and so on.
By default this approach will try to install the external dependency so that
the library, headers etc are all available when building the rest of the
project. This can cause problems as this requires the build to have write
permissions to default install locations (e.g. /usr/local/lib
on linux) and
will affect system state.
It is better not to directly install dependencies but still provide them to
build steps that rely on them. To do this you can specify INSTALL_COMMAND ""
to prevent cmake trying to install the library, and then create an imported
target using the built library and downloaded headers.
Continuing with the OpenCL example, the Khronos group provides an open source OpenCL ICD loader library, as well as OpenCL headers. These can be used together to provide the OpenCL dependency:
include(ExternalProject)
ExternalProject_Add(opencl_headers
GIT_REPOSITORY https://github.com/KhronosGroup/OpenCL-Headers
GIT_TAG master
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
)
ExternalProject_Get_Property(opencl_headers SOURCE_DIR)
file(MAKE_DIRECTORY ${SOURCE_DIR})
set(OpenCL_INCLUDE_DIR ${SOURCE_DIR}
CACHE PATH "OpenCL header directory" FORCE
)
ExternalProject_Add(opencl_icd_loader
GIT_REPOSITORY https://github.com/KhronosGroup/OpenCL-ICD-Loader
GIT_TAG master
DEPENDS opencl_headers
CMAKE_ARGS -DOPENCL_ICD_LOADER_HEADERS_DIR=${OpenCL_INCLUDE_DIR}
INSTALL_COMMAND ""
)
ExternalProject_Get_Property(opencl_icd_loader BINARY_DIR)
set(OpenCL_LIBRARY ${BINARY_DIR}/libOpenCL.so
CACHE PATH "OpenCL library location" FORCE
)
External project will download and build the headers and library, but we cannot
link them to other cmake targets as we have not created a cmake target that
contains the OpenCL dependency. We could explicitly add calls to add_library
etc here, but note that this would be identical to the target creation in the
find module. Rather than repeating ourselves we can simply set the OpenCL_*
cache variables to point to the build artifacts and call find_package(OpenCL)
to generate the OpenCL::OpenCL
target as before.
This new target will not know that it relies on the external projects being built, so one last thing to do is add dependencies to ensure that everything is built in the correct order:
add_dependencies(OpenCL::OpenCL opencl_headers opencl_icd_loader)
Playing nice with package managers
Even if you don’t want to provide support for a package manager, your users might. Equally they might like to use your project as a dependency within their own project using a package manager. As a result it is good practise to keep this use-case in mind when developing your build scripts.
Package managers will want to manage all dependencies for projects they are trying to build, which can clash with the above use of ExternalProject, causing problems such as the same dependency being provided and built multiple times, version clashes and linking errors. To avoid these problems and coexist with package managers all calls to ExternalProject as above should be wrapped in an option that allows a user to disable downloading and building any external dependencies.
option(DOWNLOAD_DEPENDENCIES
"Whether to download any dependencies not found on the system"
ON
)
This allows users of package managers to disable this functionality as the dependencies will all be provided by their package manager.
Putting it all together
Combining ExternalProject and find modules provides a powerful way to ensure that external dependencies can be found for cmake projects. But a user might not want to always download and build libraries that are available on their systems. It might be better to try and find the library locally, falling back to the option of downloading it if not found. This can easily be done with a simple cmake script:
find_package(OpenCL QUIET)
if(NOT OpenCL_FOUND AND DOWNLOAD_DEPENDENCIES)
# Use ExternalProject as above
include(ExternalProject)
ExternalProject_Add(opencl_headers
...
)
...
# Create library target using newly set up dependency
find_package(OpenCL REQUIRED)
add_dependencies(OpenCL::OpenCL opencl_headers opencl_icd_loader)
endif()
If OpenCL is found on the system, then the first call to find_package
will
find it and will set OpenCL_FOUND
to TRUE
. If it is not available on the
system then we go through the ExternalProject setup as above to download and
build OpenCL before calling find_package
again with the paths set to the build
directory.
The clinfo-lite projects gives a full example of how this all works.