Introduction to Using CMake #
C++ is a compiled language. Essentially, the source code of a C++ project needs to be converted to an executable (or a library) using a sequence of compiler invocations. For example something like
g++ -g -O2 -Wall -I ${USEFUL_DIR}/include -c foo.cpp -o foo.cpp.o
g++ -g -O2 -Wall -I ${USEFUL_DIR}/include -c bar.cpp -o bar.cpp.o
g++ -g -O2 foo.cpp.o bar.cpp.o -L ${USEFUL_DIR}/lib -luseful -o foo_bar
First question, how do we find out where useful was installed, i.e. what’s
the correct value of ${USEFUL_DIR}? Next question, how do we know it’s
-luseful? And if we’re going to use useful in another project, will we need
to repeat all the logic again? Further, what’s the equivalent of -g, -O2
and -Wall on other compilers such as Clang, MSVC, etc.
Clearly, we need a tool.
There’s a second aspect. The preferred way of writing C++ is (unless ChatGPT takes over entirely) in an IDE. The difficulty is that unless the IDE is able to compile the project, it’ll be unable to function properly. The compile button can’t work, as a consequence there’ll be not executable to debug, and none of the code navigation such as find definition, declaration or usages will work either.
With some luck the same tool can solve both problems; or more precisely solve the problem of how to compile the project in such a manner that both the IDE and common commandline tools understand.
In scientific computing at this point the only reasonable choice is CMake.
CMake is a program that converts a config file, i.e. the CMakeLists.txt into
either the configuration required for the IDE to function correctly; or a
Makefile when compiling from the commandline. 1
The two immediately obvious things it does for us is a) find where useful has
been installed and b) assemble the exact sequence of commands needed to compile
our project.
How do we use it from the CLI? In two steps. The first is to configure the build system. This is commonly done by
cmake -DCMAKE_PREFIX_PATH=${USEFUL_DIR} -DCMAKE_BUILD_TYPE=Debug -B build-debug .
Wait, but no! What’s that -DCMAKE_PREFIX_PATH? Like this we’ve not gained
anything. True, for now. Although we only need to point cmake at the folder
of useful, but we don’t need to know about setting -I ... or -L ... -luseful, nor the precise sequence of g++ commands.
But but but… yes I know. Remember, those commands depend on the compiler. So
we’ll take the win; and fix the issue with -DCMAKE_PREFIX_PATH later.
Already now, if you know useful has been installed by your package manager in
a standard location, e.g. /usr/include and /usr/lib. Then the
-DCMAKE_PREFIX_PATH=... isn’t needed. This is often the case on your own
machine.
Let’s get to the second step. Compiling:
cmake --build build-debug [--parallel] [--verbose]
Example: Eigen #
We need an example. Eigen is a nice linear algebra library commonly used in scientific computing and just happens to use CMake. Let’s try with that one:
# Get Eigen sources:
git clone https://gitlab.com/libeigen/eigen.git
cd eigen
# Configure the project:
cmake -B build .
Since, Eigen is a header-only library we’re kinda done. However, it’s interesting to skim over the output. You’ll notice that it’s detecting the C and C++ compiler and it’s supported features:
-- The C compiler identification is GNU 12.2.1
-- The CXX compiler identification is GNU 12.2.1
...
-- Performing Test COMPILER_SUPPORT_WERROR
-- Performing Test COMPILER_SUPPORT_WERROR - Success
...
-- Performing Test COMPILER_SUPPORT_OPENMP
-- Performing Test COMPILER_SUPPORT_OPENMP - Success
It seems to figure out if OpenMP is available and probably checks if -Werror
works. A bit further down we find information about libraries detected:
-- Found CHOLMOD: /usr/include
-- Found UMFPACK: /usr/include
-- Found KLU: /usr/include
It all wraps up with some talk about installation. Right, CMake takes care of
that too. Let’s try it out: but where will it install it to? Some unsuitable
default. Let’s change that quickly, before it tries to install something into
/usr/include.
Let’s reconfigure and install Eigen
$ cmake -DCMAKE_INSTALL_PREFIX=${HOME}/bit-bcast/trash/eigen -B build .
$ cmake --install build
-- Install configuration: "Release"
...
-- Installing: /home/lucg/bit-bcast/trash/eigen/include/eigen3/Eigen/Core
-- Installing: /home/lucg/bit-bcast/trash/eigen/include/eigen3/Eigen/Dense
...
-- Installing: /home/lucg/bit-bcast/trash/eigen/share/eigen3/cmake/Eigen3Targets.cmake
-- Installing: /home/lucg/bit-bcast/trash/eigen/share/eigen3/cmake/Eigen3Config.cmake
-- Installing: /home/lucg/bit-bcast/trash/eigen/share/eigen3/cmake/Eigen3ConfigVersion.cmake
Four things to observe:
this is decent but it’s no dependency manager, unlike pip, or cargo;
CMake has a notion of different “configurations”;
you’ll recognize files such as Eigen/Dense being copied to a sensible location;
CMake installs a couple of *.cmake files.
Summary #
We have a good solution for building one project which depends on other
projects. In principle, we could build against different versions of useful
simply by changing the value of CMAKE_PREFIX_PATH. We can also build with
multiple compilers. However, the practicalities of maintaining say three
versions of useful with both Clang and GCC; and then building our project
with all six combinations are unsatisfactory.
However, it doesn’t take too much imagination2 to envision a tool which can
automate the entire process. It must know that our project has a dependency on
useful. Furthermore, the tool needs to know the URL where to find useful.
Then the tool can download, configure, build, and install useful into a
${USEFUL_DIR} of it’s own choice. Now it can pass ${USEFUL_DIR} to our
CMake command. Note, that by simply changing the value if ${USEFUL_DIR}
multiple versions of useful can coexist.
For scientific computing the name of this tool is Spack.