Qt项目构建提速实战:将spdlog从header-only切换到编译模式
1. 现象与定位那30秒到底被谁吃掉了1.1 项目现状Qt Widgets CMake spdlog 的典型组合事情要从一个很常见的项目场景说起。一个基于 Qt Widgets 的桌面工具界面不算复杂但业务逻辑里到处都要打日志。日志库选了 spdlog理由很直白接口简单、性能好、社区活跃而且对 CMake 天然友好find_package或者FetchContent拉下来就能用。项目本身用 CMake 组织目标平台是 Windows MSVC偶尔也会切到 MinGW 验证编译。最开始我是这样写的find_package(spdlog REQUIRED) target_link_libraries(app PRIVATE Qt6::Widgets spdlog::spdlog)代码里每个需要记日志的 .cpp 文件顶部都会有这么一行#include spdlog/spdlog.h开发到中期的时候问题开始变得扎眼每次哪怕只改一个业务文件重新编译链接最少要等半分钟有时候接近40秒。这个时间消耗在增量构建里尤其明显——明明只改了几个字符为什么整个构建要这么慢更让人崩溃的是一旦改了某个被广泛 include 的头文件基本就等于一次全量重来一等等两三分钟。团队里新来的同事第一次跑编译直接愣在那块进度条面前以为卡住了。我当时花了一点时间分析构建耗时最后定位到两个大头一是 Qt 自身的头文件二是 spdlog 的 header-only 模式。这两个叠加在一起几乎每个 .cpp 的编译单元都在重复做同样的模板实例化和头文件展开工作。Qt 的部分属于使用 Qt 就必须支付的成本真正可以通过工程手段砍掉的大头正是 spdlog。1.2 header-only 模式为什么这么贵spdlog 默认是 header-only 的意味着你 include 进来的不是一个接口声明而是一大坨模板实现。spdlog 本身依赖 fmt 库fmt 也是模板大户。当你写spdlog::info(...)编译器要实例化 fmt 的格式化模板、spdlog 的 logger/sink 模板这还没算上 Windows 下 debug 版的运行时检查和异常处理相关代码。关键在于这些实例化工作不是做一次就完事而是每个包含 spdlog.h 的编译单元都要做一遍。假设项目有 30 个 .cpp 引用了 spdlog那就是 30 份完全重复的模板实例化。现代 CPU 再快也架不住这种重复劳动。我也试过用 cl 的/Bt和 CMake 的编译时间统计去看耗时分布结论非常一致时间基本都花在编译这个环节而且是重复编译。这里打个生活化的比方header-only 模式像每个办公室职员自己复印一份同样的产品说明书加起来浪费大量纸张和时间而把 spdlog 编译成库相当于只复印一份放在档案室大家需要时去查阅即可不用每个人都持有完整副本。前者能让每个人拿到就能看但对于每天都在大量编译的团队项目来说这成本太奢侈了。2. 方案取舍为什么最终选择了编译成库2.1 先看看另外两条路PCH 和 ccache在真正决定改 spdlog 之前我试过两条大众普遍推荐的优化路线。第一条是预编译头文件PCH。Qt 项目里做 PCH 有点麻烦因为 Qt 的 moc 流程和 CMake 的target_precompile_headers配合得并不特别顺尤其是项目里既有 Qt 又有第三方库的时候PCH 里的头文件一旦和 moc 生成的 moc_*.cpp 冲突编译错误会非常难查。而且 PCH 只是缓存了展开结果第一次全量构建依然要付出同样的成本另外 PCH 对不同编译选项组合很敏感两个 target 如果编译选项不同比如一个 /std:c17 一个 /std:c20PCH 就得建两份维护成本直接翻倍。第二条是 ccache。ccache 对重复构建确实有效比如你清掉 build 目录再全量编译它可以命中缓存把之前已经编好的目标文件直接拿回来。但它有个致命的尴尬点它优化的是重复不是首次。在开发机换分支、切换编译选项、或者首次拉代码的时候ccache 几乎帮不上忙。而且 Windows 下 ccache 需要额外给编译器套一层 launcher 包装编译器路径、环境变量弄错的话反而拖慢速度。一句话ccache 是个不错的兜底方案但治标不治本。于是一轮尝试之后我回到根本问题上为什么不直接把 spdlog 编成静态库让模板实例化只发生一次这其实就是 spdlog 官方一直提供的 compiled 模式只是很多人从来没有认真看过它的 CMake 选项。2.2 编译模式的核心原理把模板实例化集中到一处spdlog 在 CMake 里有一个选项SPDLOG_HEADER_ONLY默认是 ON。设为 OFF 时它会用spdlog.cpp、stdout_sinks.cpp、file_sinks.cpp等编译单元去显式实例化那些较重的模板代码然后打包成spdlog或spdlog_static静态库。此后你的业务代码 include spdlog.h 时看到的就只是一个轻量的接口头文件真正的模板地狱只在 spdlog 库自身构建时发生一次。这里有三个关键点必须理解透彻不然很容易踩坑。第一编译模式并不会改变 API调用方代码一行都不用改唯一要改的是 CMake 配置。这是它相比换库、或者自己封装一层日志接口来说最大的优势。封装日志接口虽然能让业务代码和具体日志库解耦但封装本身也有成本而且要动员全项目改代码ROI 远不如一个 CMake 选项来得快。第二由于官方的 CMake 逻辑里SPDLOG_HEADER_ONLYOFF和SPDLOG_COMPILED_LIBON是配套的你在 add_subdirectory 或者 FetchContent 之前设置set(SPDLOG_HEADER_ONLY OFF)即可它会自动构建静态库并提供spdlog::spdlog目标。第三SPDLOG_ACTIVE_LEVEL这类编译宏对编译模式极其关键。spdlog 的很多代码是围绕SPDLOG_ACTIVE_LEVEL做条件编译的如果你在业务代码里定义了SPDLOG_ACTIVE_LEVELSPDLOG_LEVEL_DEBUG但编译 spdlog 库时没定义那么库里某些低级别的代码分支就可能没被编译进去链接期或运行期能看出问题最常见的现象就是 debug 日志静默消失。这部分我在后面的避坑章节会展开讲。至于 PCH 和 ccache我的态度是spdlog 变成静态库之后PCH 只需要照顾 Qt 自己的头文件和项目公共头文件压力小了很多可以放心加上去。也就是说编译 spdlog 成库是根治PCH ccache 是锦上添花。两个组合使用收益才是最大化的。3. 实操CMakeLists.txt 改造全过程3.1 选型FetchContent、find_package 还是 subdirectory在 Qt 项目里引入 spdlog市面上常见三种方式。find_package(spdlog REQUIRED)需要系统里预先装了 spdlog或者通过 vcpkg 统一提供。这种方式最干净但前提是团队里每个开发机都得装好版本还要一致。我在一开始就是这么写的后来发现同事的机器上 spdlog 版本不一致日志格式偶尔出现细微差异排查起来很烦。git submodule 加add_subdirectory的方式可控性好但 submodule 更新不够方便clone 时还要多一步递归拉取的步骤新人很容易漏掉。FetchContent是 CMake 官方推荐可以锁定版本首次 configure 时自动下载源码。代价是每次 configure 缓存失效会重新拉取网络慢的机器会卡住但可以用本地缓存或者提前下载好源码目录来规避。我个人实际选的是 FetchContent。理由很朴素对一个团队项目来说它最容易保证一致性。版本写死在 CMakeLists 里所有人拉下来构建结果一致配合离线包或者内网镜像下载问题也不难解决。下面代码就是基于 FetchContent 的完整做法。3.2 完整 CMake 配置代码cmake_minimum_required(VERSION 3.16) project(MyQtApp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # ---------- spdlog 编译模式 ---------- set(SPDLOG_HEADER_ONLY OFF CACHE BOOL FORCE) set(SPDLOG_BUILD_EXAMPLE OFF CACHE BOOL FORCE) set(SPDLOG_BUILD_TESTS OFF CACHE BOOL FORCE) set(SPDLOG_BUILD_BENCH OFF CACHE BOOL FORCE) include(FetchContent) FetchContent_Declare( spdlog GIT_REPOSITORY https://github.com/gabime/spdlog.git GIT_TAG v1.13.0 ) FetchContent_MakeAvailable(spdlog) # ---------- Qt ---------- find_package(Qt6 REQUIRED COMPONENTS Widgets) # ---------- 应用目标 ---------- set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) qt_add_executable(app main.cpp mainwindow.cpp mainwindow.h ) target_compile_definitions(app PRIVATE SPDLOG_ACTIVE_LEVELSPDLOG_LEVEL_DEBUG) target_link_libraries(app PRIVATE Qt6::Widgets spdlog::spdlog )这里有两个细节必须强调。第一个细节SPDLOG_HEADER_ONLY OFF必须写在FetchContent_MakeAvailable(spdlog)之前而且最好用CACHE BOOL FORCE的形式。因为如果不强制写进缓存spdlog 内部的 option 声明会在第二次 configure 时用默认值覆盖你的设置。我自己就吃过这个亏第一次 configure 明明设了 OFF后来改了一点 CMake 重新 configure它又变回 header-only 了构建时间突然回升查了半天才反应过来。把这个 set 命令写成 cache 变量强制覆盖一劳永逸。第二个细节业务 target 上一定要加SPDLOG_ACTIVE_LEVELSPDLOG_LEVEL_DEBUG。这个宏主要是为了让你能控制不同 build type 下日志级别的编译裁剪。如果你只保留 info 及以上的日志改成SPDLOG_LEVEL_INFO即可。库自身的构建也要保持一致的宏否则就会出现后面要讲的日志级别静默消失问题。3.3 编译选项上做的一点额外优化在 Windows MSVC 下我还加过几个编译器选项覆盖 spdlog 自身构建和整个项目if(MSVC) add_compile_options(/MP /Zc:preprocessor /Zi /Ob2) endif()/MP是多进程编译spdlog 库构建时虽然源码文件不多但多进程能压榨满 CPU/Zc:preprocessor是让 MSVC 使用符合标准的预处理器spdlog 和 fmt 在标准预处理器下展开更快也更不容易出怪问题/Ob2是内联扩展对 spdlog 这种大量模板内联的库有明显帮助/Zi顺手带上调试符号方便将来排查日志库内部的崩溃。这些算不上高深配置但组合起来你会发现 spdlog 库自身的构建时间也能压缩不少。尤其是/Zc:preprocessor很多老项目里没见过它一旦打开编译 spdlog 那种模板套模板的代码速度差别还是挺明显的。4. 进阶组合拳把增量编译压到毫秒级4.1 搞定 spdlog 之后集中火力处理 Qt 头文件spdlog 转成静态库以后业务代码里 include spdlog.h 的成本已经变得很小了。但增量构建 30 秒的问题还没有完全解决——剩下的时间大头是 Qt 的头文件。每个 .cpp 都要展开 QtWidgets 相关的头文件几千个类声明、模板、宏定义这个成本是实打实的。我用了 CMake 的target_precompile_headers来做项目级 PCHtarget_precompile_headers(app PRIVATE QMainWindow QString QWidget spdlog/spdlog.h )有人可能会问spdlog 都已经是编译模式了还往 PCH 里放干嘛这里其实不矛盾。PCH 缓存的是头文件展开加语法分析的结果编译模式缓存的是模板实例化和代码生成的结果。往 PCH 里放 spdlog.h 可以把 include 阶段的开销也消掉成本几乎为零收益虽然小但也算白捡。真正的收益大户是 Qt 那几行尤其 QMainWindow、QString 这种高频头文件。需要提醒的是target_precompile_headers里的头文件顺序会影响 PCH 的正确性一般把 Qt 头放在前面然后再放第三方库。如果 spdlog.h 放在 Qt 头前面在 MSVC 下可能出现符号重定义或者头文件依赖顺序问题。我实测下来先 Qt 后 spdlog 的顺序最稳。4.2 配合 ccache 解决重复构建的问题PCH 解决的是每个编译单元重复解析头文件的成本但它解决不了另一个问题切换分支以后编译器会因为文件时间戳变化而重新编译大量文件。这时候就需要 ccache 出马。Linux 和 macOS 下 ccache 配置很简单安装之后在 CMake 里指定find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM AND NOT WIN32) set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ${CCACHE_PROGRAM}) endif()Windows 下稍微麻烦一点需要把 ccache 配置为 cl.exe 的 launcher。建议直接在命令行层面用cmake -DCMAKE_CXX_COMPILER_LAUNCHERccache配合 Visual Studio 自带的 cl.exe 实现实测可用。注意首次配置时要把 CMake 缓存删干净否则编译器检测可能不一致产生一堆莫名其妙的缓存 miss。ccache 和 PCH 有一个经典冲突点如果两个编译单元用的是不同的 PCH 文件ccache 会 miss如果 PCH 文件内容变了包含它的所有编译单元的缓存键都会失效。所以建议 PCH 只放真正稳定不变的高频头文件也就是 Qt 和 spdlog业务头文件别塞进去。这也正好可以和我上面 PCH 的内容设计对上。4.3 实测数据从 30 秒到几百毫秒说了这么多最后放一份我自己项目里的实测数据场景是只改一个 cpp 里的日志输出内容后重新构建方案状态全量构建单文件增量构建空增量构建无任何修改优化前header-only spdlog2分40秒28~35秒约 12 秒只改 spdlog 编译模式1分10秒2~4秒约 700 毫秒编译模式 PCH58秒0.8~1.5秒约 300 毫秒编译模式 PCH ccache首轮约 60 秒次轮约 40 秒0.5~1秒约 100 毫秒单文件增量从 30 秒量级降到 1 秒上下小文件甚至能到几百毫秒基本就是点了编译就能看到结果。空增量构建从 12 秒降到 0.1 秒级别对于频繁跑 CMake 和 IDE 自动构建的场景整个人的工作节奏完全不一样了。标题里说的毫秒级主要落在这个场景空增量和极简增量确实能做到毫秒级反馈。顺带补充一句如果你的项目里有很多带 Q_OBJECT 的类第一次全量构建依然省不掉因为 AUTOMOC 要先跑 moc。不过 moc 对增量构建的影响相对小CMake 对 automoc 的依赖跟踪做得还不错这一块不是我优化的大头。5. 常见问题与避坑记录5.1 编译模式下的宏一致性绕不开的坑前面反复提到SPDLOG_ACTIVE_LEVEL我要把踩坑经历讲完整。有一次我把 spdlog 切到编译模式后发现项目里的spdlog::debug(...)打出来的日志不见了trace 日志也全没了但 info 以上都正常。排查过程花了一整个下午最后发现原因很有意思业务 target 上定义了SPDLOG_ACTIVE_LEVELSPDLOG_LEVEL_DEBUG但 spdlog 库本身在 FetchContent 构建时没有这个宏于是它内部的代码在SPDLOG_ACTIVE_LEVEL处用了默认的SPDLOG_LEVEL_INFO。也就是说库里对 debug 级别日志的编译分支被裁掉了调用端却以为还有于是一堆 lower 级别日志静默消失。解决方式很简单在 FetchContent 之前把宏也塞给 spdlog 目标set(SPDLOG_ACTIVE_LEVEL SPDLOG_LEVEL_DEBUG CACHE STRING FORCE)或者在FetchContent_MakeAvailable之后对 spdlog 目标单独加target_compile_definitions(spdlog PRIVATE SPDLOG_ACTIVE_LEVELSPDLOG_LEVEL_DEBUG)注意第二种方式必须用PRIVATE因为 spdlog 的接口头文件里也会引用这个宏如果库的内部编译定义和外部不一致照样出幺蛾子。我建议直接用第一种 cache 变量的方式让库和外部目标在整个 CMake 层面上保持一致最省心。5.2 项目里已经有 fmt 的情况警惕重复符号很多 Qt/C 项目里其实已经用了 fmt或者依赖了某个库间接带入了 fmt。spdlog 默认会内嵌 fmt在编译模式下如果静态库 spdlog 和业务代码里各自带了一份 fmt 实例化轻则体积膨胀重则 Windows 链接器上直接报重复符号错误。解决办法是把 spdlog 配置成使用外部 fmtset(SPDLOG_FMT_EXTERNAL ON CACHE BOOL FORCE) find_package(fmt REQUIRED) target_link_libraries(app PRIVATE fmt::fmt)但这样有个连锁问题外部 fmt 的版本必须和 spdlog 兼容。比如 spdlog v1.13 依赖 fmt 10.x 系列的接口如果拿 fmt 9.x 来凑编译期会掉进一堆隐式转换错误里。如果项目里已有的 fmt 版本太老建议统一升级或者老老实实用 spdlog 内嵌 fmt不要在中间暧昧不清。我的建议是能外部化就外部化两个同 namespace 的符号在链接器里打架那可是很折磨人的。5.3 MinGW 下的链接差异与 DLL 遗漏在 Windows 上用 MinGW 编译 Qt 项目时如果你习惯写-lspdlog这种旧式链接参数常会遇到找不到库的问题。MinGW 对库名和路径的解析方式与 MSVC 不同它的静态库名通常是libspdlog.a而不是spdlog.lib用错了链接器参数就会报cannot find -lpublic之类的诡异错误。网上有不少人把这个报错和 Qt 的 CAN 通讯程序 crash 混在一起排查其实根本不是一回事。我的建议是在 CMake 里永远用 target 名称链接不要手写-l参数。只要spdlog::spdlog这个 imported target 存在CMake 会替你处理不同编译器的库名差异。如果还是遇到链接错误先检查一件事你用的 target 到底是静态库还是共享库。有时候因为顶层BUILD_SHARED_LIBS被外部意外打开CMake 给你生成的是共享库而 MinGW 下共享库运行时需要额外拷贝 DLL漏掉 DLL 就会出现启动即崩溃甚至报 0000005 访问违例。排查时打印一下 target 属性get_target_property(_type spdlog::spdlog TYPE) message(STATUS spdlog target type: ${_type})如果是SHARED_LIBRARY而你确实只想要静态库删掉 build 目录重新 configure再检查BUILD_SHARED_LIBS有没有被其他地方改掉。5.4 PCH 与 Qt moc 的相爱相杀前面讲了target_precompile_headers的运用这里补充一点避坑提示。Qt 的 AUTOMOC 会为每个头文件生成 moc_*.cpp 编译单元如果这些 moc 文件也使用 PCH在 MSVC 下会和 Qt 自身的头文件定义产生冲突常见报错是类似 macro redefinition 或者 Q_OBJECT macro not found 这样的诡异信息。我遇到一次比较典型的PCH 里放了QMainWindow但某个 moc 文件展开时由于 PCH 顺序问题导致 Q_OBJECT 宏在类定义之前没有被正确展开。社区讨论里其实反复提醒过 PCH 和 Qt 配合要谨慎但很多人没放在心上。我的建议是如果项目里 Q_OBJECT 类很多且 AUTOMOC 开着第一次做 PCH 时可以先只对少数几个纯业务逻辑、不包含 Q_OBJECT 的 .cpp 目标启用验证没问题再铺开到全项目。这样既能吃到 PCH 的头文件展开加速又不会让 moc 生成代码和 PCH 打架。6. 一些个人体会踩过这些坑之后我对构建优化的看法改变了不少。以前总觉得提高编译速度是性能调优的范畴应该从编译器参数、并行度这些方面下手。现在我明白最有效的优化永远是让编译器少干活而不是让编译器干活干得更快。把 spdlog 从 header-only 切成 compiled 模式本质上就是把几十个编译单元重复进行的模板实例化压缩成库内一次性完成。这种结构性优化带来的收益是任何编译参数微调都无法比拟的。另外团队项目里任何优化都要以可复现、可维护为前提。用 FetchContent 锁版本、用 cache 变量固定 spdlog 选项、把宏统一写到顶层 CMakeLists这些看着不起眼却能保证团队里每个人拿到代码都能跑出同样快的构建。项目的构建速度和代码一样需要持续维护才能保持健康。如果你也正被 Qt 项目里 spdlog 的编译时间折磨不妨今天就把它切到编译模式再顺手加一层 PCH改动量不大但体验上的提升是肉眼可见的。最后再提醒一句改完记得把 build 目录删掉重新 configure 一次别让缓存里残留的 header-only 目标默默拖慢你。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →