实测对比:用CMake构建LLVM时,Make和Ninja到底差多少?

📅 发布时间:2026/7/13 23:47:47 👁️ 浏览次数:
实测对比:用CMake构建LLVM时,Make和Ninja到底差多少?
实测对比用CMake构建LLVM时Make和Ninja到底差多少如果你是一位长期与大型C项目打交道的开发者那么“构建时间”这个词很可能已经成了你日常开发中一个挥之不去的痛点。想象一下每次修改几行代码都要等待长达数十分钟甚至数小时的编译链接过程那种感觉无异于在高速公路上堵车。尤其是在处理像LLVM这样规模庞大、依赖复杂的项目时构建工具的选择往往直接决定了你的开发效率是“龟速”还是“起飞”。今天我们不谈空洞的理论也不做简单的参数罗列而是从一个最实际、最朴素的问题出发当使用CMake作为项目配置器时选择传统的Make还是后起之秀Ninja在构建LLVM这样的巨无霸项目时时间差距究竟有多大这个差距是否值得我们改变工作流我们将通过一次贴近真实开发环境的实测用具体的数据和操作细节为你揭开这个谜底。无论你是正在为团队技术栈选型的架构师还是饱受漫长编译之苦的一线开发者这篇文章都将提供一份直观的参考。1. 构建工具简史与核心哲学在深入实测之前我们有必要先理解一下Make和Ninja这两位“选手”的设计背景与核心思想。这并非为了掉书袋而是为了让我们能更深刻地理解后续性能差异的根源。Make诞生于1977年几乎是构建工具领域的“活化石”。它的核心是一个基于文件依赖和时间戳的规则执行引擎。你编写一个Makefile定义好目标target、依赖prerequisite和规则recipeMake便会根据文件的修改时间来判断哪些目标需要重新构建。它的语法相对灵活功能强大并且经过数十年的发展拥有极其庞大的生态和几乎无处不在的兼容性。然而这份“厚重”也带来了问题Make在解析复杂的Makefile、计算依赖图以及启动大量子进程时可能产生不小的开销。注意Make的灵活性是一把双刃剑。它允许你编写复杂的Shell命令和条件逻辑但这也意味着Make在执行前需要做更多的解析工作尤其是在递归调用或包含大量文件时。相比之下Ninja则是一个极简主义的产物。它由Chromium浏览器项目的开发者创建其设计目标非常明确尽可能快地启动并执行构建任务。Ninja的构建文件通常是build.ninja不鼓励甚至不支持复杂的逻辑它期望由更高级的构建系统如CMake、Meson来生成一个扁平的、最优化的依赖描述文件。Ninja自身则专注于一件事以最快的速度遍历这个依赖图并高度并行地执行编译命令。我们可以用一个简单的表格来对比两者的设计哲学特性MakeNinja设计年代1977年2010年代核心目标通用、灵活的构建规则描述极致的构建速度文件语法功能丰富支持条件、函数极度精简近乎配置文件依赖解析运行时解析支持动态生成依赖依赖图由前端生成Ninja直接加载启动开销相对较高极低适用场景通用项目尤其是需要复杂构建逻辑的场合大型、依赖关系明确的项目追求极致构建速度Ninja的这种“将复杂性推给前端自身保持极简”的理念使其在大型项目构建中具有先天优势。它不需要在每次构建时都去解析复杂的逻辑而是直接加载一个已经计算好的“作战地图”然后全力开火。2. 测试环境与LLVM项目准备任何性能测试脱离环境谈结果都是不严谨的。为了让我们的对比更有参考价值我选择在一个具有代表性的开发机器上进行。同时为了控制变量我们需要确保两次构建的对象是完全一致的。测试机器配置如下CPU: AMD Ryzen 7 5800X (8核心16线程)内存: 64 GB DDR4存储: 1TB NVMe SSD操作系统: Ubuntu 22.04 LTS关键软件版本:CMake: 3.22.1GNU Make: 4.3Ninja: 1.10.2Git: 2.34.1选择LLVM项目作为测试对象再合适不过了。它是一个用C编写的、模块化且高度复杂的编译器基础设施代码量巨大超过百万行依赖关系错综复杂是检验构建工具性能的“试金石”。为了节省克隆时间和磁盘空间我们采用浅克隆Shallow Clone的方式获取源码。# 进入一个合适的工作目录例如 ~/workspace cd ~/workspace # 使用 --depth1 参数进行浅克隆只获取最新的一次提交 git clone --depth1 https://github.com/llvm/llvm-project.git这里简单解释一下--depth1它指示Git只克隆仓库最近的一次提交记录及其关联的文件而不是完整的历史。对于LLVM这种历史悠久的项目完整克隆可能需要数GB甚至更多数据而浅克隆能在几秒钟内完成非常适合我们这种一次性的构建测试。克隆完成后我们创建一个独立的构建目录。这是一个CMake推荐的最佳实践可以将生成的文件如.o,.a, 构建系统文件与源代码分离保持源码树的干净也方便我们进行多次不同配置的构建。# 进入克隆的源码目录 cd llvm-project # 为Make构建创建并进入一个构建目录 mkdir build-make cd build-make至此我们的测试舞台已经搭好。接下来我们将分别使用CMake生成Makefile和Ninja构建文件并进行计时构建。3. 实战使用Make构建LLVM/Clang首先登场的是传统选手Make。我们的目标是构建LLVM项目中的Clang前端并采用Debug模式因为Debug构建会包含调试信息且关闭优化编译单元更多更能体现构建工具在调度大量小任务时的能力。步骤一使用CMake生成Makefile在build-make目录下执行以下CMake命令cmake -DLLVM_ENABLE_PROJECTSclang \ -DCMAKE_BUILD_TYPEDebug \ -DLLVM_TARGETS_TO_BUILDhost \ -G Unix Makefiles \ ../llvm让我们拆解一下这几个关键参数-DLLVM_ENABLE_PROJECTSclang指定只构建Clang子项目。LLVM包含众多子项目如lld, libcxx等只构建Clang可以缩短测试时间但依然是一个相当大的任务。-DCMAKE_BUILD_TYPEDebug设置为调试构建模式。这会开启-g调试标志并通常关闭编译器优化-O0生成更多的中间代码编译时间更长。-DLLVM_TARGETS_TO_BUILDhost只构建当前主机平台本机的后端避免交叉编译带来的额外时间。-G Unix Makefiles明确指定生成器为“Unix Makefiles”。在Linux上这通常是CMake的默认行为但显式指定可以避免歧义。../llvm指向源码目录下的llvm子目录这是LLVM项目的主CMakeLists.txt所在位置。命令执行成功后你会在build-make目录下看到生成的Makefile以及大量CMake缓存文件。步骤二使用Make进行并行构建并计时接下来我们使用make命令启动构建并利用time命令来记录整个过程消耗的“真实时间”real time。# 使用time命令计时-j16指定并行任务数为16与CPU线程数一致 time make clang -j16这里有几个细节值得关注目标指定我们构建的目标是clang而不是默认的all。这确保了两次测试构建的工作量完全对等。并行度-j16参数告诉Make同时运行最多16个任务job。这个数字通常设置为处理器线程数或稍多一点以最大化利用CPU资源。这是影响构建速度最关键的因素之一。计时工具time命令会输出三个时间real: 实际流逝的“墙钟时间”也就是你从按下回车到命令结束所等待的时间。user: 所有CPU在用户态运行该命令所花费的时间总和。sys: 所有CPU在内核态运行该命令所花费的时间总和。 对于构建速度的直观感受我们最关心的是real时间。在我的测试机器上执行上述命令后终端开始疯狂滚动输出编译信息。经过漫长的等待最终time命令给出了结果real 121m52.314s user 1032m7.204s sys 45m23.611s结果分析实际耗时real:2小时1分52秒。这是一个相当长的时间足以喝好几杯咖啡甚至看一部电影。用户态CPU时间user: 超过1000分钟。这远大于实际时间是因为16个逻辑核心在并行工作CPU时间被累加了。这说明了构建过程充分利用了多核性能。内核态时间sys: 45分钟主要花费在文件I/O、进程创建等系统调用上。这个数据为我们建立了一个性能基线。接下来我们清理现场为Ninja的测试做准备。# 回到源码根目录清理或创建新的构建目录 cd .. rm -rf build-ninja mkdir build-ninja cd build-ninja4. 实战使用Ninja构建LLVM/Clang现在轮到Ninja上场。测试流程与Make类似但CMake的生成器参数和构建命令有所不同。步骤一使用CMake生成Ninja构建文件在build-ninja目录下执行CMake命令这次使用-G Ninja参数。cmake -DLLVM_ENABLE_PROJECTSclang \ -DCMAKE_BUILD_TYPEDebug \ -DLLVM_TARGETS_TO_BUILDhost \ -G Ninja \ ../llvm这个命令与之前的唯一区别就是将生成器-G从Unix Makefiles换成了Ninja。执行成功后你会看到目录下生成的是build.ninja文件而不是Makefile。这个文件内容非常紧凑几乎全是路径和命令没有多余的逻辑。步骤二使用Ninja进行构建并计时Ninja的构建命令通常通过CMake的--build模式来调用这样更通用。当然你也可以直接运行ninja clang。# 使用CMake的build命令它内部会调用ninja time cmake --build . --target clang --parallel 16 # 或者直接使用ninja命令效果等价 # time ninja clang -j16这里我们使用了cmake --build这是一个跨生成器的通用接口。参数--parallel 16指定了并行任务数。同样我们使用time命令进行计时。构建过程再次开始。一个直观的感受是Ninja的输出信息比Make要简洁快速得多它几乎不做什么“思考”立刻就开始编译任务。等待一段时间后结果出炉real 85m7.158s user 1015m33.442s sys 42m18.775s结果分析实际耗时real:1小时25分7秒。用户态CPU时间user: 与Make构建接近说明两者都让CPU满载工作了。内核态时间sys: 略低于Make构建。5. 数据对比与深度解析现在让我们将两次测试的关键数据放在一起进行直接对比构建工具实际耗时 (real)用户态CPU时间 (user)内核态时间 (sys)相对速度提升GNU Make2h 1m 52s (121.9m)1032m45m基准Ninja1h 25m 7s (85.1m)1015m42m约30%核心结论一目了然在构建LLVM/Clang这个特定任务上Ninja比Make快了大约36分钟性能提升接近30%。这个差距从何而来我们可以从几个方面深入剖析启动与调度开销这是Ninja最大的优势所在。Make在开始执行前需要解析整个Makefile计算复杂的依赖图并且其递归式的变量展开和函数调用会带来开销。而Ninja加载的build.ninja文件是一个近乎“扁平化”的依赖图它被设计得可以被极快地解析。对于大型项目Ninja几乎能做到“秒启动”立刻开始编译任务而Make可能需要花费数十秒甚至几分钟来“思考”从哪里开始。依赖检查效率Ninja使用一个持久化的构建状态数据库通常是.ninja_log文件可以非常快速地判断文件是否需要重新编译。Make虽然也基于时间戳但在处理大量文件时其stat()系统调用的开销累积起来也不可小觑。并行任务调度两者在-j16下都能充分利用多核。但Ninja的调度器被认为更加高效和激进能更早地发现可并行任务并派发出去减少了任务间的空闲等待时间。输出与日志Make默认会打印出它正在执行的每一条命令。在并行构建时大量终端的输出I/O本身也会成为瓶颈。Ninja默认采用更简洁的单行状态更新输出减少了终端I/O的竞争这也是其“感觉”更快的原因之一。提示你可以通过给Make加上-ssilent参数来禁止输出命令但这通常只在最终构建时使用开发中我们可能需要看到错误信息。一个重要的观察两者的user时间非常接近。这说明在“纯编译”这个核心工作上两者消耗的CPU资源是差不多的。差距主要体现在real时间上即组织、调度、启动这些编译任务所花的“管理开销”。Ninja通过极简的设计将这部分开销降到了最低。6. 超越速度Make与Ninja的生态与适用性思考速度固然重要但选择构建工具不能唯速度论。我们需要在一个更广阔的视角下审视两者的差异。Make的优势领域无处不在的兼容性它是POSIX标准的一部分几乎在所有Unix-like系统上都默认存在。对于需要高度可移植性或在不方便安装新软件的环境如某些嵌入式工具链、老旧服务器中Make是唯一可靠的选择。灵活的终极武器你可以直接在Makefile里编写复杂的Shell脚本、使用条件判断、函数和宏。对于需要动态生成源文件、根据复杂条件选择编译路径等“非标准”构建流程Make的灵活性无可替代。深厚的生态数十年的积累意味着几乎所有开源库都提供了Makefile有海量的教程、工具和最佳实践可供参考。Ninja的优势领域大型项目的构建速度正如实测所示对于代码库庞大、依赖关系固定的项目如Chromium, LLVM, AndroidNinja的速度优势是决定性的。与高级构建系统的完美搭配Ninja生来就是作为CMake、Meson、GN等构建系统的后端。你几乎不需要手动编写build.ninja而是由这些前端工具为你生成最优化的版本。这实现了关注点分离你用高级语言描述项目Ninja负责以最快速度执行。可预测性和可重现性由于其文件的静态性Ninja的构建行为更加可预测。同样的build.ninja文件在不同机器上只要工具链相同会产生几乎完全一致的构建过程。如何选择一个实用的建议对于新启动的C/C项目尤其是预计会成长为中大型的项目我强烈推荐将CMake Ninja作为默认组合。CMake负责处理跨平台和复杂的项目逻辑描述Ninja负责提供最快的构建速度。这是当前业界许多高性能项目如UE4, Fuchsia的选择。# 你的日常开发流程可能变成这样 mkdir build cd build cmake -G Ninja -DCMAKE_BUILD_TYPERelWithDebInfo .. ninja -j$(nproc) # 使用所有可用核心 # 或者如果你只修改了某个模块可以只构建它 ninja my_specific_target对于需要维护传统项目或者项目构建流程包含大量动态生成、定制化脚本的情况Make可能仍然是更合适或更无奈的选择。不过即使是这类项目也可以考虑部分迁移例如用CMake生成Makefile为未来向Ninja过渡留下可能。在我的实际开发经历中从Make切换到Ninja带来的体验提升是立竿见影的。不仅仅是那30%的完整构建时间节省更重要的是增量构建的响应速度。当你只修改了一两个文件后重新构建Ninja那种“瞬间完成”的感觉极大地改善了编码的心流状态减少了上下文切换的成本。这种效率提升对于追求快速迭代的现代开发团队来说价值可能远超一次完整构建节省的几十分钟。