CMake项目常见的编译加速方式汇总

背景

本文主要介绍CMake项目,在不改动或较小改动项目代码的情况下,如何减少编译时间。同时简要介绍其原理和使用场景。包含ccache,编译集群,CMake依赖优化,避免动态编译宏和UnityBuild。

加快编译速度

分通解,增量编译和全量编译三个方面来介绍。通解开启简单方便,增量编译减小项目重编范围。全量编译减少编译数量。

通解

使用ccache

原理及使用场景

ccache会缓存编译结果,在编译文件和编译宏未发生变化时,直接返回缓存的编译结果。

使用场景:在文件内容未变化时加速效果近乎100%,开启简单方便。

搭配make使用时,效果有限。

开启方式

使用yum install ccache安装

在CMake中添加如下内容开启ccache

find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
    message(STATUS "Found ccache: ${CCACHE_PROGRAM}")
    set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
    set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
else()
    message(WARNING "ccache not found, proceeding without ccache")
endif()

验证方式

ccache -s直接查看命中缓存次数。

$ ccache -s                        
cache directory                     /root/.ccache
cache hit (direct)                   467
cache hit (preprocessed)               2
cache miss                          1009
files in cache                      1701 这里是缓存命中的次数
cache size                         656.0 Mbytes
max cache size                       1.0 Gbytes

使用编译集群

原理及使用场景

cpp项目编译时,每个源码文件都是一个编译单元,所以可以使用多线程并行编译。编译集群简单粗暴增加编译的并行数量。

使用场景:大规模编译时效果明显,收费。

开启方式

公司内部devops中就有编译加速,接入很简单。

增量编译

介绍如何减少不必要的编译项目。

如何发现不必要的编译项目?

CMake生成后,使用Make编译时,会打印一些日志,如下所示。

$ make MyExecutable              
Scanning dependencies of target MyExecutable
[ 33%] Building CXX object CMakeFiles/MyExecutable.dir/src/main.cpp.o
[ 66%] Building CXX object CMakeFiles/MyExecutable.dir/src/Foo.cpp.o
[100%] Linking CXX executable MyExecutable
[100%] Built target MyExecutable

从日志可以看到在编译MyExecutable时, Make将main.cpp.o和Foo.cpp.o重新编译,说明这两个文件所依赖的文件发生了变化。

将在依赖优化原理处介绍如何查看其依赖内容。这里只需要知道通过日志,可以观察本次编译的内容是否符合预期,如果发现不该编译的文件进行了编译,则可以查看其依赖内容,判断是否可以进行优化。

CMake依赖优化

依赖优化原理

CMake生成之后会生成如下所示的depend.make文件

# depend.make
CMakeFiles/MyExecutable.dir/src/Foo.cpp.o: ../src/Foo.cpp
CMakeFiles/MyExecutable.dir/src/Foo.cpp.o: src/version.h

CMakeFiles/MyExecutable.dir/src/main.cpp.o: ../src/Foo.h
CMakeFiles/MyExecutable.dir/src/main.cpp.o: ../src/main.cpp

depend.make文件中描述了文件间的依赖情况。Foo.cpp.o依赖Foo.cpp和version.h。当version.h的修改时间戳变化时,会导致Foo.cpp.o重新生成。

如不想让冒号左侧的文件重新生成,则需要保证冒号右侧文件的修改时间戳不发生变化。如果内容无变化,但是修改时间戳发生变化,同样会导致左侧文件重新生成。

项目中存在的协议文件,需要转换后生成代码文件才能在代码中使用。如果转换时使用批量处理,会导致协议文件未变化,但代码文件发生了变化。代码文件发生变化后,所有依赖此代码文件的目标都会重新生成。

正确的做法应该是在协议文件发生变化时,再进行代码文件生成。手动识别较繁琐,正好CMake提供了此功能。

使用场景

编译时存在动态生成文件时

弊:需要理清生成文件的依赖关系

开启方式

set(XML_SRCS)
file(GLOB CFG_FILES "*.xml")
set(GameResFile GameRes.h)
# 主要命令,定义了产出文件GameResFile,生成脚本ConvertGameRes.sh,生成GameResFile所需的文件CFG_FILES
add_custom_command(OUTPUT ${GameResFile}
        COMMAND bash ConvertGameRes.sh
        DEPENDS ${CFG_FILES}
        WORKING_DIRECTORY ${RES}/script/one
)
# 将所有动态生成文件添加到一个目标如XmlHeaders中
list(APPEND XML_SRCS ${GameResFile})
add_custom_target(XmlHeaders ALL DEPENDS ${XML_SRCS})
# 需要使用GameRes.h的服务添加XmlHeaders为依赖
add_dependencies(AServer XmlHeaders)

编译AServer时会检查其依赖XmlHeaders是否有变化,XmlHeaders检查XML_SRCS即GameResFile是否需要重新生成。

当GameResFile的依赖文件CFG_FILES发生变化或产物GameResFile不存在时,调用ConvertGameRes.sh进行生成。

避免使用频繁变化的编译宏

原理

在CMake中可以使用add_definitions添加编译宏,之后在代码文件中即可使用此宏获取对应的内容。如下所示打印编译时间。

# 定义BUILD_TIME宏
add_definitions(-DBUILD_TIME=\"${BUILD_TIME}\")

# 使用宏
std::cout << "Current machine IP address: " << BUILD_TIME << std::endl;

编译宏会存储到如下CMake生成的flags.make中

# CMAKE generated file: DO NOT EDIT!
# Generated by "Unix Makefiles" Generator, CMake Version 3.17

# compile CXX with /usr/bin/c++

CXX_DEFINES = -DBUILD_TIME=123

每当编译宏发生变化时flags.make的修改时间也会发生变化。在build目录中搜索依赖此文件的内容,如下所示。

$ grep -R "flags.make" ./*                   
./build.make:include CMakeFiles/MyExecutable.dir/flags.make
./build.make:CMakeFiles/MyExecutable.dir/src/main.cpp.o: CMakeFiles/MyExecutable.dir/flags.make
./build.make:CMakeFiles/MyExecutable.dir/src/Foo.cpp.o: CMakeFiles/MyExecutable.dir/flags.make

可以看到main.cpp.o和Foo.cpp.o都依赖了flags.make。

所以每当cmake重新生成,宏内容发生变化时,flags.make也会发生变化,导致依赖flags.make的文件重新生成。

使用场景

项目存在变化的编译宏时使用。

开启方式

// 文件CompileVersion.cpp.in

#define _BUILDDATE "@BUILDDATE@"
#define _BUILDIP "@BUILDIP@"

#include "CompileVersion.h"

std::string GetCompileVersionStr()
{
    std::string strVersion;

    strVersion += "\033[40;32mSo Build Date   \033[0m: "_BUILDDATE"\n";
    strVersion += "\033[40;32mSo Build Host   \033[0m: "_BUILDIP"\n";
    return strVersion;
}

// 文件 CompileVersion.h
#pragma once

#include <string>

std::string GetCompileVersionStr();

// 文件 CMake
configure_file(CompileVersion.cpp.in CompileVersion.cpp)

configure_file会将模板CompileVersion.cpp.in中@BUILDDATE@替换成CMake中BUILDDATE变量。同时将产出放在cpp中,对外提供h文件。这样当版本信息发生变化时仅会编译CompileVersion.cpp

全量编译

使用UnityBuild

原理及使用场景

默认的编译方式会逐个处理每个编译文件,如果A.cpp和B.cpp都include了common.h,编译A.cpp和B.cpp时common.h会被处理两次。将A.cpp和B.cpp合并为一个编译单元后,common.h仅会被处理一次。

弊:命名空间污染,重新编译范围扩大,同时手动组织比自动组织效果更好。

自动组织开启方式 cmake3.16添加

# CMake中打开UnityBuild
SET(CMAKE_UNITY_BUILD OFF)
# 设置成一个文件所需的数量
set(CMAKE_UNITY_BUILD_BATCH_SIZE 16)
# 关闭指定文件的UnityBuild,使其单独编译
set_source_files_properties(A.cpp PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON)

手动组织开启方式 cmake3.18添加

这里项目未实际使用,直接粘贴的文档的内容。

add_library(example_library
            source1.cxx
            source2.cxx
            source3.cxx
            source4.cxx)

set_target_properties(example_library PROPERTIES
                      UNITY_BUILD_MODE GROUP
                      )

set_source_files_properties(source1.cxx source2.cxx source3.cxx
                            PROPERTIES UNITY_GROUP "bucket1"
                            )
set_source_files_properties(source4.cxx
                            PROPERTIES UNITY_GROUP "bucket2"
                            )

补充

项目中未使用的内容

  1. 编译速度-使用PCH,使用后未发现明显的加速效果。
  2. 链接速度-使用多线程链接器,加快链接速度,目前链接很快,倾向于不做改动。

加快部署速度

简单的快速部署方式:常用的SSH工具由于权限一般不能使用,问GPT生成一个带账号密码以及能够接受文件的HTTPServer,功能只需要在接受文件并校验后,替换文件,执行服务器重启脚本。可以将脚本输出放到HTTP的回包中,上传文件和接收文件时进行压缩和解压缩,降低对带宽的需求。

需要部署服务器时可以通过curl命令直接上传文件后触发部署。