手里只有一台低规格云服务器时,构建优化很容易变成猜参数:双核是不是只能用 -j2?开到 -j4 会不会更快?装上 ccache 是否就能解决所有重复编译?
我用一台已有的阿里云 ECS,以 Zstandard 为真实负载,完成了 105 次正式构建,并另外执行校准和干净目录复验。结果是:Ninja 全量构建从串行的 58.28 秒降至双并行的 29.23 秒;已有缓存命中时,清空构建目录后的重建从约 28.71 秒降至 0.83 秒。但首次内容编辑没有显示稳定缓存收益,自适应策略也没有超过已经选对参数的固定 -j2。
这次实践的价值,在于把这些结论连同不确定性一起保留下来,形成可以重新执行、重新计算的实验工程。
负载选择 Zstandard v1.5.7,这次配置产生 75 个编译单元,包括 74 个 C 单元和 1 个 x86-64 汇编单元。它的串行构建约一分钟,足以观察并行收益,也没有把小机器推到内存耗尽边缘。不过它不能代表模板密集的 C++ 工程。
Make 和 Ninja 都由同一份 CMake 配置生成:Release、相同优化参数、关闭 LTO,统一程序、静态库和测试目标。正式实验前比较规范化后的编译命令,确认源码、工具链、编译参数、测试集合及检查的产物集合一致,避免把目标数量不同误认为生成器性能差异。
每个主要条件运行五次,按区组打乱执行顺序。先进行独立校准,再开始正式统计;保留失败、跳过和失效采集记录,不把它们偷偷删成“全成功”。正式样本共包含 40 次并行度实验、30 次缓存实验、15 次策略验证,以及 20 次采集开销实验。
构建结束还要验证产物存在,运行固定种子的短 fuzzer 测试,并对确定性输入完成压缩、解压和 SHA256 往返核验。这是统一的测试子集,不等于跑完上游全部测试。配置、编译和测试分别记录耗时,文中的加速比针对编译阶段。
双核上,j2 拿到了主要收益
下表是五次全量构建耗时的中位数,单位为秒。这里的全量构建指新的构建目录、关闭编译缓存,没有清空 Linux 页缓存。
| 构建工具 | j1 | j2 | j3 | j4 |
|---|---|---|---|---|
| Ninja | 58.28 | 29.23 | 29.56 | 30.98 |
| CMake Unix Makefiles | 60.93 | 31.10 | 32.36 | 33.37 |
Ninja 从 j1 到 j2 的两组中位数比约为 1.99,五个配对均更快。CPU 使用率按“一个逻辑 CPU 满负荷为 100%”计算,中位数从约 98.71% 上升至 189.85%,说明第二个可用逻辑 CPU 得到了利用。
j3 和 j4 没有继续改善中位数。Ninja j2 与 j3 仅相差 0.33 秒,样本范围明显重叠,不应把这个小差异包装成稳定排名。与此同时,Ninja 的采样进程树 RSS 观测最大值从 j1 的约 215 MiB 上升至 j4 的约 388 MiB,非自愿上下文切换也增加了。

本次所有 W1 构建中,最低 MemAvailable 仍约为 942 MiB。没有 Swap,也没有观察到内存耗尽,因此更合理的解释是:该负载主要受可用 CPU 并行能力限制,而不是已经进入内存或 Swap 抖动。上下文切换与耗时的相关性也不能独立证明因果关系。
Ninja 在本次各组中位数上均较低,但 j2 的配对只有四次快于 Make,探索性区间跨过比值 1。我据此选择 Ninja j2 作为后续实验配置,同时保留结论边界:这不是 Make 与 Ninja 的普遍性能排名。完整样本与配对结果可直接检查。
ccache:重建很快,首次修改不一定受益
缓存实验固定 Ninja j2,严格区分六种场景。热缓存试验先预热,再清空本次构建目录并在同一路径重建;增量试验则保留构建目录,实际修改源文件中的帮助文字,验证对象文件确实更新。
| 场景 | 构建中位数/秒 | 观察结果 |
|---|---|---|
| 无缓存全量 | 28.71 | 基线 |
| 空缓存全量 | 31.53 | 每次 74 次未命中 |
| 热缓存清目录重建 | 0.83 | 每次 74 次直接命中、零未命中 |
| 内容编辑,缓存关闭 | 1.28 | 每次重建 2 个对象 |
| 内容编辑,缓存开启 | 1.23 | 每次 2 次未命中 |
| 无修改构建 | 0.13 | 没有重建对象 |
热缓存重建相对无缓存全量的未舍入中位数比约为 34.45,五个配对均更快。这项收益有明确前提:编译结果已经存在于缓存。预热构建本身仍需约 29.89–31.74 秒,没有算进 0.83 秒。如果每次都丢掉缓存重新预热,就得不到这种重复重建收益,更不能把它称为整个 CI 流水线加速 34 倍。
这里的 100% 命中率只针对 74 个可缓存 C 编译调用,另一个汇编单元不经过本次 C/C++ launcher。缓存结果来自机器可读计数器的前后差值,而非只看终端上显示的百分比。

两个不那么“漂亮”的发现同样重要。首先,空缓存全量构建的五个配对全部慢于无缓存:首次写入缓存需要付出成本。其次,首次内容编辑虽然分组中位数略有下降,但只有两次配对更快,没有稳定收益。修改后的编译结果尚未入库,相关的两次编译都未命中;不能仅凭 1.28 秒和 1.23 秒就宣称优化有效。
ccache 在项目目录中部署:固定 RPM 和公钥摘要,验证签名后解包,不执行 RPM 安装脚本。每次场景使用独立缓存,上限 512 MiB。这样可以明确空缓存与热缓存的边界,也不影响机器上的其他缓存。
把经验参数变成可解释的准入决策
如果固定 j2 已经很好,为什么还需要调度器?因为可用内存、容器限制和工作负载会变化;一个可重复的 CI 系统应解释本次为什么允许启动,而不只是永久写死参数。
本项目采用构建前静态决策。先读取 CPU affinity 和 cgroup v2 祖先 quota,确定有效 CPU 上限;再将系统 MemAvailable 与 cgroup 剩余额度一起考虑。策略预留 max(384 MiB, 有效内存上限的 20%),用匹配工作负载的串行历史峰值乘以 1.25 估计保守任务预算,另加 64 MiB 余量。最终并行度同时受 CPU、内存预算和已验证上限约束。
历史按源码树、工具链版本和编译参数匹配,模型在验证前冻结。信息不完整、历史失效或预算连 j1 都不足时,策略返回等待,而不是把 Swap 算成可以随意使用的编译内存。系数是本实验的保守启发式,不是跨项目的安全定理。
| 策略 | 次数 | 实际并行度 | 耗时中位数/秒 |
|---|---|---|---|
| 固定 j1 | 5 | 1 | 58.59 |
| 固定 j2 | 5 | 2 | 28.95 |
| auto | 5 | 五次均为 2 | 29.69 |
auto 找到了本机已经验证过的 j2,但没有超过固定 j2。它相对 j1 的收益来自选择了并行编译,不能归功于某种额外加速机制。所有固定策略也都成功,因此本次没有证据说明 auto 提高了现场成功率。
低内存降并行度、等待和历史失效由单元测试验证,测试输入明确属于合成数据。我没有为了制造亮眼结果而在这台机器上施加 OOM 压力。目前也没有理由引入运行中动态调整任务数量、Kubernetes 或分布式服务。