Building code with CMake
These exercises are adapted from the CodeRefinery lesson “CMake introduction and hands-on workshop”.
Note
Running the examples below on
Dardel?
In this case you may need to load the CMake
module:
$ module load PDC CMake
Exercise CMake-1: Compiling “Hello, world” with CMake
In this exercise we will compile a single source file to an executable. Choose your favorite language.
Create a new folder and in the folder create a source file:
hello.cpp:
#include <cstdlib>
#include <iostream>
void say_hello() {
std::cout << "Hello world" << std::endl;
}
int main() {
say_hello();
return EXIT_SUCCESS;
}
Use and adapt the C++ example. In terms of CMake, both are very similar.
hello.f90:
pure function say_hello() result(message)
implicit none
character(len=11) :: message
message = 'Hello world'
end function
program example
implicit none
character(len=11) :: say_hello
print *, say_hello()
end program
The folder contains only the source code. We need to add a file called
CMakeLists.txt
to it. CMake reads the contents of these special files when generating the build system:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES CXX)
add_executable(hello hello.cpp)
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES Fortran)
add_executable(hello hello.f90)
We are ready to call CMake and get our build system:
$ cmake -S. -Bbuild
And finally build our executable:
$ cmake --build build
Try to also run the executable.
Note
Instead of creating a build directory, configuring, and building implicitly:
$ cmake -S. -Bbuild
$ cmake --build build
… some people prefer to do this more explicitly (the build directory does not have to be called “build”):
$ mkdir build
$ cd build
$ cmake ..
$ make
Both approaches will work for us.
Exercise CMake-2: Building and linking a library
Only rarely we have one-source-file projects and more realistically, as projects grow, we split them up into separate files. This simplifies (re)compilation but also helps humans maintaining and understanding the project.
We stay with the toy project but also here things got more real and more modular and we decided to split the project up into several files:
hello.cpp:
#include "greeting.hpp"
#include <cstdlib>
int main() {
say_hello();
return EXIT_SUCCESS;
}
greeting.cpp:
#include "greeting.hpp"
#include <iostream>
void say_hello() {
std::cout << "Hello world" << std::endl;
}
greeting.hpp:
#pragma once
void say_hello();
hello.f90:
program example
use greeting, only: say_hello
implicit none
print *, say_hello()
end program
greeting.f90:
module greeting
implicit none
public say_hello
private
contains
pure function say_hello() result(message)
implicit none
character(len=11) :: message
message = 'Hello world'
end function
end module
Your first goal: try to build this by adapting the CMakeLists.txt
from
earlier by first adding all the source files into the same add_executable
.
CMake can of course be used to produce libraries as well as executables. The
relevant command is add_library
. You can link libraries can be linked into
other targets (executables or other libraries) with target_link_libraries
.
Your second goal: now try to build a greeting library and link against this library instead of collecting all sources into the executable target:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES CXX)
add_executable(hello hello.cpp)
add_library(greeting
SHARED
greeting.cpp
greeting.hpp
)
target_link_libraries(hello PRIVATE greeting)
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES Fortran)
add_executable(hello hello.f90)
add_library(greeting
SHARED
greeting.f90
)
target_link_libraries(hello PRIVATE greeting)
Which solution did you like better? Discuss the pros and cons.
Exercise CMake-3: Detecting your environment
Sometimes we need to write code that performs different operations based on
compile-time constants.
Like in this example (example.cpp
):
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() {
#ifdef IS_WINDOWS
return std::string("Hello from Windows!");
#elif IS_LINUX
return std::string("Hello from Linux!");
#elif IS_MACOS
return std::string("Hello from macOS!");
#else
return std::string("Hello from an unknown system!");
#endif
}
int main() {
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}
We can do this with the following CMakeLists.txt
:
# set minimum cmake version
cmake_minimum_required(VERSION 3.14)
# project name and language
project(example LANGUAGES CXX)
# define executable and its source file
add_executable(example example.cpp)
# let the preprocessor know about the system name
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_compile_definitions(example PUBLIC "IS_LINUX")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
target_compile_definitions(example PUBLIC "IS_MACOS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
target_compile_definitions(example PUBLIC "IS_WINDOWS")
endif()
Please try it out before moving on.
We achieved this with a combination of host system
introspection and the target_compile_definitions
command.
Now before moving on, create a new directory.
A common customization is to apply processor-specific compiler flags. We can
gain such information on the host system with the built-in
cmake_host_system_information
command.
Another thing that is common and convenient is to have a single file containing
all these compile-time constants, rather than passing them to preprocessor.
This can be achieved by having a scaffold file and then letting CMake
configure it with configure_file
after discovering the values for all the
necessary compile-time constants.
Here is an example source file (example.cpp
):
#include "config.h"
#include <cstdlib>
#include <iostream>
int main() {
std::cout << "Number of logical cores: " << NUMBER_OF_LOGICAL_CORES << std::endl;
std::cout << "Number of physical cores: " << NUMBER_OF_PHYSICAL_CORES << std::endl;
std::cout << "Processor is 64Bit: " << IS_64BIT << std::endl;
std::cout << "Processor supports SSE2 instructions: " << HAS_SSE2 << std::endl;
std::cout << "OS name: " << OS_NAME << std::endl;
std::cout << "OS sub-type: " << OS_RELEASE << std::endl;
// we will add these two later:
// std::cout << "Compiler: " << COMPILER << std::endl;
// std::cout << "Compiler version: " << COMPILER_VERSION << std::endl;
return EXIT_SUCCESS;
}
The file config.h
does not exist, it will be generated at configure time
from config-template.h
:
#pragma once
#define NUMBER_OF_LOGICAL_CORES @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define IS_64BIT @_IS_64BIT@
#define HAS_SSE2 @_HAS_SSE2@
#define OS_NAME "@_OS_NAME@"
#define OS_RELEASE "@_OS_RELEASE@"
Here is the CMakeLists.txt
which takes care of introspection and also
generates the file config.h
:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES CXX)
include(CMakePrintHelpers)
foreach(key
IN ITEMS
NUMBER_OF_LOGICAL_CORES
NUMBER_OF_PHYSICAL_CORES
IS_64BIT
HAS_SSE2
OS_NAME
OS_RELEASE
)
# query the item ${key} and save its value in the variable _${key}
cmake_host_system_information(RESULT _${key} QUERY ${key})
cmake_print_variables(_${key})
endforeach()
add_executable(example example.cpp)
# this is here because the config.h will be generated in PROJECT_BINARY_DIR
target_include_directories(example
PRIVATE
${PROJECT_BINARY_DIR}
)
configure_file(config-template.h config.h @ONLY)
Your goal is to adapt the above example and make it possible to get the compiler and compiler version into the output of your code:
#include "config.h"
#include <cstdlib>
#include <iostream>
int main() {
std::cout << "Number of logical cores: " << NUMBER_OF_LOGICAL_CORES << std::endl;
std::cout << "Number of physical cores: " << NUMBER_OF_PHYSICAL_CORES << std::endl;
std::cout << "Processor is 64Bit: " << IS_64BIT << std::endl;
std::cout << "Processor supports SSE2 instructions: " << HAS_SSE2 << std::endl;
std::cout << "OS name: " << OS_NAME << std::endl;
std::cout << "OS sub-type: " << OS_RELEASE << std::endl;
// we will add these two later:
// std::cout << "Compiler: " << COMPILER << std::endl;
// std::cout << "Compiler version: " << COMPILER_VERSION << std::endl;
return EXIT_SUCCESS;
}
Hints:
You will only need to uncomment these and only have to modify
configure-template.h
.You will not need to modify
CMakeLists.txt
.The relevant variable names are
CMAKE_CXX_COMPILER_ID
andCMAKE_CXX_COMPILER_VERSION
(or replaceCXX
byC
orFortran
).
Solution
#pragma once
#define NUMBER_OF_LOGICAL_CORES @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define IS_64BIT @_IS_64BIT@
#define HAS_SSE2 @_HAS_SSE2@
#define OS_NAME "@_OS_NAME@"
#define OS_RELEASE "@_OS_RELEASE@"
#define COMPILER "@CMAKE_CXX_COMPILER_ID@"
#define COMPILER_VERSION "@CMAKE_CXX_COMPILER_VERSION@"
What else to record when configuring? Here are some ideas:
Code version and/or Git hash
Compiler versions
Compiler flags
Compile-time definitions
Library versions
Some environment variables
Discuss how they can be useful for you and others running your code.
Exercise CMake-4: Finding and using dependencies
CMake offers a family of commands to find artifacts installed on your system:
find_file
to retrieve the full path to a file.find_library
to find a library, shared or static.find_package
to find and load settings from an external project.find_path
to find the directory containing a file.find_program
to find an executable.
For this exercise, choose one of the examples below which is closest to your work and most relevant for your code.
Try to compile and run.
Browse the online documentation of the
Find<PackageName>.cmake
module (e.g.FindOpenMP.cmake
).Try to compile with verbosity (
cmake --build build -- VERBOSE=1
) and verify how the imported target modified compile flags and definitions.Bonus: Try to adapt what we learned to an example which uses the BLAS or LAPACK library or your favorite library.
Note
Running the examples below on
Dardel?
In this case you may need to load the PrgEnv-gnu
module:
$ module load PrgEnv-gnu
Source file (example.cpp
):
#include <cstdlib>
#include <iostream>
#include <omp.h>
int main() {
#pragma omp parallel
{
std::cout << "hello from thread " << omp_get_thread_num() << std::endl;
}
return EXIT_SUCCESS;
}
And the CMakeLists.txt
file:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES CXX)
add_executable(example example.cpp)
find_package(OpenMP REQUIRED COMPONENTS CXX)
target_link_libraries(example PRIVATE OpenMP::OpenMP_CXX)
Source file (example.f90
):
program example
use omp_lib
!$OMP PARALLEL
print *, "hello from thread ", omp_get_thread_num()
!$OMP END PARALLEL
end
And the CMakeLists.txt
file:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES Fortran)
add_executable(example example.f90)
find_package(OpenMP REQUIRED COMPONENTS Fortran)
target_link_libraries(example PRIVATE OpenMP::OpenMP_Fortran)
Source file (example.cpp
):
#include <iostream>
#include <mpi.h>
int main() {
MPI_Init(NULL, NULL);
int id;
MPI_Comm_rank(MPI_COMM_WORLD, &id);
std::cout << "hello from rank " << id << std::endl;
MPI_Finalize();
}
And the CMakeLists.txt
file:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES C CXX)
add_executable(example example.cpp)
find_package(MPI REQUIRED COMPONENTS C)
target_link_libraries(example PRIVATE MPI::MPI_C)
Source file (example.f90
):
program example
use mpi
implicit none
integer :: error
integer :: id
call MPI_Init(error)
call MPI_Comm_rank(MPI_COMM_WORLD, id, error)
print *, "hello from rank ", id
call MPI_Finalize(error)
end program
And the CMakeLists.txt
file:
cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES Fortran)
add_executable(example example.f90)
find_package(MPI REQUIRED COMPONENTS Fortran)
target_link_libraries(example PRIVATE MPI::MPI_Fortran)
Exercise CMake-5: CMake-ify your own project
This may not be easy and you will probably need help from a TA or the instructor but is a great exercise and we can try to do this together. You can find good tips in “CMake introduction and hands-on workshop”.
Where to find more examples and exercises
Curious about more CMake? As a next step, we recommend to work through the “CMake introduction and hands-on workshop”.