在快手技术团队通过自研 Java17 透明协程技术实现业务无侵入的性能提升后,性能优化的视角并未止步于此,而是进一步深入系统底层——聚焦 C++ 构建系统与编译技术的优化,持续挖掘代码生成与执行效率的极限。
这篇文章将系统阐述快手在编译领域的一系列优化实践与技术探索,重点介绍我们针对 Propeller 技术提出的 match&infer 方案。该方案有效解决了其在实际应用中的两大痛点:过期 profile 导致优化失效以及与 AutoFDO 协同优化冲突,从而修复了性能衰减问题,且无需增加编译时长。
目前,相关核心代码已全部开源,并正式合入 LLVM 社区,为编译器生态贡献了来自快手的最佳实践成果。相关的 RFC 和 PR 如下:
一、快手编译技术总览
构建系统与编译优化是软件开发中的核心环节,直接决定了代码的编译效率、运行性能以及项目的可维护性。
快手编译器团队始终致力于为内部开发者提供高性能、稳定、安全且易用的编译技术,其核心承载产品 KBuild 在这一过程中发挥了重要作用。KBuild 不仅支撑了快手大部分业务的编译构建需求,还在研发效率、稳定性、代码质量和性能优化等方面创造了价值:
- 研发效率:通过分布式和缓存技术,KBuild 将 C++ 工程编译时间从超 1 小时缩短至 5 分钟内,大幅提升团队效率;
- 稳定性:通过灰度发布控制基础组件,减少代码变更引发的稳定性问题;
- 代码质量:配备静态扫描机制,发现开发 Review 阶段难以察觉的问题;
- 性能优化:通过编译优化技术节省服务器资源,优化延时提升用户体验。

从快手 CPU 性能优化方法论来看,提升系统吞吐能力、高效节省服务器资源,其核心策略主要从 IPC、Util 和 QPKI 这三个关键维度展开深度考量。编译优化主要聚焦 IPC 维度,具体来说,我们通过 TMA,分析得到编译优化的终极目标是消除前端瓶颈、后端瓶颈和错误的分支预测。利用运行时的信息,结合全局优化能力,编译器能够对代码进行更合理的布局与调度,充分利用硬件特征提升访存与计算的效率,以及分支预测的准确度,优化前端瓶颈、后端瓶颈和错误的分支预测的不良影响,最终提升 IPC。

编译优化,作为提升软件性能的关键一环,其核心在于通过编译器生成更高性能的机器指令。相较于其他优化途径,编译优化主要的特点是:不仅能带来成本收益,还往往伴随延时的下降,提升用户体验。
快手的 C++ 编译优化技术覆盖整个编译流程,从编译期的参数调优、AutoFDO(自动反馈优化),到链接期的 LTO 优化、Propeller 优化,再到链接后的 BOLT 优化,以及基础库的 SIMD、高性能 Protobuf、JSON 优化等等,这些技术不仅仅是简单地应用开源工具,而是根据快手的实际需求进行了深度改造和优化。这些编译优化技术已在重点业务部门实现了超过 30% 的覆盖率,累计为快手节省了价值数千万的机器资源。同时,编译团队持续探索创新技术,不断在编译优化领域寻求突破。本文接下来将聚焦于 AutoFDO、ThinLTO、BOLT、Propeller 这四项关键技术。

二、编译优化技术详解
当前编译流程中面临三大核心挑战,具体包括:
- 编译器缺乏程序动态运行信息,错失优化良机:没有程序实际运行数据支撑的情况下,编译器难以做出精准的优化决策,导致潜在的性能提升机会被白白浪费。
- 编译器优化粒度受限,难以实现跨模块优化:编译器的优化通常局限于单个模块内,难以跨越模块边界进行更全面的优化,这限制了程序整体性能的提升空间。
- 编译器生成的二进制代码布局非最优:编译器生成的二进制代码布局可能并非最佳,这会影响程序的执行效率和性能表现。
为了有效应对这些挑战,我们引入并实施了 AutoFDO、ThinLTO、BOLT 和 Propeller 这四项编译优化技术,有效地解决上述问题,提升程序的运行效率和性能水平。接下来将详细介绍其是如何实现的。

2.1 AutoFDO
2.1.1 FDO 技术原理
Profile-guided optimization(PGO)又称 Feedback-directed optimization(FDO)是指利用程序运行过程中收集到的 Profile 数据,来指导编译器在原来的基础上做出更好的优化,从而提高程序的性能。比如编译器中有一个很重要的优化方法——函数内联(inline),该优化方法在判断函数是否能否被内联的时候,默认使用的是一个经验阈值——225,也就是如果函数指令复杂度(cost)超过了 225,编译器就会放弃内联。但是如果结合 Profile 数据,编译器在处理的过程中会对那些热点函数调大阈值,这样让热点函数更容易被内联,从而对整体性能产生正向影响。

编译器里面的许多优化都是社区工程师预先设定好的一些规则或者启发式算法,目的是为了在大部分场景下都能够有优化效果,但具体到某一种场景是否最优就不一定了。在有了程序真实运行的数据——函数调用、分支跳转、条件是否实际执行等之后,编译器就能够在原来通用优化的情况上做出一些定制性的改进,下图列出了一些编译器在 PGO 下能够获得增强的优化能力。

根据 Profile 数据的收集方式不同,PGO 又可以分为两类:Instrumentation-based PGO 和 Sample-based PGO。为了能够在快手 C++ 业务上大规模落地 PGO 编译优化技术,目前快手采用的是 Sample-based PGO 中的 AutoFDO 来收集 Profile 数据。
2.1.2 支持流水线级别的 Profile 文件,提供更为有效的性能优化手段
在面对流水线与集群环境的特定挑战时,传统的 AutoFDO 方法遇到了限制,因为它仅支持单个 perf.data 文件作为输入。然而,在快手内部的大多数场景中,一条流水线往往对应着多个服务,单个 perf.data 文件难以全面反映整条流水线及其所涵盖的多服务集群的特征。为了应对这一挑战,我们对 AutoFDO 进行了改进,以更好地适应流水线级别的性能分析需求。改进后的 AutoFDO 流程中的关键步骤和逻辑如下。

AutoFDO 里从 runtime address 到 binary address 再到最终对应的 inline stack 的整个逻辑如上图所示:
- 原始数据采集:Perf 采集到的原始数据中包含采样到的 runtime address 和对应 pid;
- 地址解析与映射:通过查询 pid 对应的 mmap info,可以 runtime address 所属的区间,进而确定对应的 binary 和 file offset;
- 堆栈信息解析:通过查询 binary 对应的 section layout,可以确定 file offset 所属的段,最终可以计算出 binary address;
- 数据转换与存储:通过 dawrf info 和 debug line 信息,确定最终的 inline stack。

经过改造后,新增的 PerfReader 模块将 perf.data 内的信息(PID, mmap info, virtual address)与 metadata 结合,解析成自定义的 sample 格式,perf.data 中的每个采样都对应成一个 sample,存储于 database。最终,AutoFDO 的输入由单机的 perf.data 变成了数据库中存储的 sample 的集合。
2.2 ThinLTO
2.2.1 LTO 的技术原理
LTO(Link-Time Optimization)是编译器在链接阶段做的一种全局优化。编译阶段的优化只能局限在编译单元内部,而链接期由于能看到全部的 .o 文件,相当于有全局信息,能做一些跨编译单元之间的优化,比如内联、无用代码消除等。

LTO 在编译器内部根据实现的不同分为如下两种:
-
Full(Monolithic) LTO
Full LTO 模式下将所有的源码文件编译成 IR,然后将这些 IR 合并成一个巨型的 IR 文件,链接器对合并后的 IR 文件做优化和链接,生成最终的二进制。由于所有的编译单元被合成了一个大的模块,需要一次性加载到内存中,对内存的要求比较大并且只能单线程处理,导致 LTO 优化的耗时非常久(在快手一个典型的 c++ 业务上进行实测,发现整体编译耗时需要一个小时左右),另外如果用户有任何源文件代码的变动,都需要整个程序重新链接,相当于编译缓存形同虚设。
-
ThinLTO
ThinLTO 通过在编译阶段每个编译单元生成 Summary 信息,之后再汇总所有编译单元的 Summary 信息,为每个编译单元生成一份分析摘要信息,其中包括外部模块的依赖关系,这样每个编译单元就可以根据该信息并行的处理来加速整个优化过程,业务链接的整体耗时能够从 Full LTO 的一小时降到 10 分钟左右,但是相比不开启 LTO 的情况下链接耗时还是增加了几百倍。
2.2.2 引入自研构建系统,多项改造使得链接时间从小时级降至秒级

在不改造构建系统的情况下,ThinLTO 只能做到在链接期多线程并发,如果希望进一步降低 ThinLTO 的链接时间,需要将链接阶段进一步拆分,将链接阶段拆分为链接一阶段(ThinLink)、链接二阶段(ThinBackend)、链接三阶段(Link),并在此基础上去开发分布式编译、编译缓存功能。

为了将 ThinLTO 引入自研构建系统,我们进行了多项创新改造,新增了三大核心功能:
- 四步编译支持:不同于传统的 compile、link 环节,新增 thin_compile、thin_link、thin_backend、link 环节;
- 分布式编译能力:原始的 ThinLTO 虽然能够通过多线程并发处理,但是并发程度受限于单机的 cpu 核心数,不足以同时支撑业务需要链接的文件。通过改造 distcc/distccd,支持分布式,将任务分发到编译集群的多个机器来增大并发,从而降低整体的耗时;
- 缓存机制优化:业务代码每次迭代往往只会变更一小部分文件,大部分文件是不变的,相应其对应的 .o 文件也是不变的,如果一个编译单元和其依赖的外部模块都没有变化的话,我们就可以复用上次编译的结果,降低整体耗时。通过改造 ccache,支持 thin backend 阶段的产物的缓存功能。
通过上述改造和优化措施,我们将链接阶段时间从最初的一小时(开启 LTO 时)大幅缩短至约 30 秒左右。与不开启 LTO 的情况相比,整体编译时间仅增加了几十秒,实现了编译效率的显著提升。
2.3 BOLT
2.3.1 BOLT 的原理
BOLT(Binary Optimization and Layout Tool)是由 Meta 推出的一项 post-link 优化技术,能够在不需要程序源码的情况下,对编译后的二进制文件进行优化。BOLT 现已合入 LLVM 主干,成为其中一个子模块。

BOLT 的基本流程如上图所示:
- 收集程序运行时性能数据:在正常的工作负载下运行程序,收集性能数据(分支跳转信息等);
- 数据转换:结合二进制中的调试信息,将收集到的原始数据通过工具转换成 BOLT 需要的 Profile 数据;
- 优化生成新的二进制文件:根据输入的 Profile 数据,BOLT 对二进制文件进行优化,生成新的二进制文件。
BOLT 通过调整可执行文件的代码布局,减少 icache-miss,itlb-miss 等来优化程序,其中最主要的两个优化是对 function、BB(Basic Block) 根据 Profile 数据进行重新布局。下图是业务的可执行文件调整前后的代码热力图对比。

从代码热力图的对比可以看出,优化前的可执行文件代码热点都是随机出现在地址空间内的,经过 BOLT 重新调整后,热点代码段相对更集中,代码的局部性更好。
2.3.2 解决 AutoFDO 和 BOLT 无法叠加的问题
AutoFDO 与 BOLT 共用存在的问题:使用同一份 perf.data 文件,生成 AutoFDO 和 BOLT 各自的 Profile 文件无法带来叠加优化。

如果经历两次独立的反馈编译优化:第一次使用 AutoFDO 优化,第二次使用 BOLT 优化,两者的优化效果可以叠加:第一次 AutoFDO 优化会有 8% 优化,第二次叠加 BOLT 优化会有 12% 优化。如果只经历一次反馈编译优化,使用同一份 perf.data 生成两种 Profile,最终不会带来叠加优化。
- 问题原因:
经过分析发现,BOLT 优化失效的原因是,第二轮 AutoFDO 生成的二进制指令上有差异,当 BOLT 发现 Profile 和二进制指令不匹配时,会保守的放弃优化。由于 AutoFDO 是根据 Profile 信息,对编译器原有的启发式优化进行增加,这就导致即使源码不变,Profile 的变化会导致 AutoFDO 执行的优化不同,最终导致 AutoFDO 生成的二进制指令有差异。

如上图,在原代码不变的情况下,用一份新的 Profile,重新执行 AutoFDO,也能看到 BB 块的指令内容发生了变化。
- 在 BB 0xA 中:由于 Profile 的变化,影响了 inline 策略,额外把 bar 函数 inline 进来了;
- 在 BB 0x1 中:由于 BB 0xA 的指令变更,导致后续的指令地址发生变化,指令 0x7: jne 0x13 的跳转目的变更为了 0x17;
- 在 BB 0x17 中:受前面 BB 影响,只是指令地址发生了变化,指令内容没有变化。
使用第一轮的二进制采样得到的 Profile,无法和第二轮生成的二进制匹配,从而导致 BOLT 放弃了对 Foo 函数的优化。
- 解决方案:
Meta 在 CGO 2024 中提出 BOLT 遇到 Profile 过时问题的解决方案,我们将此应用到 AutoFDO 与 BOLT 共用方案上。方案分为两步:match 和 infer,先根据指令内容尽量匹配 BB 块,匹配不上的块,根据已经匹配上的块进行推导。

在 match 阶段使用的策略如下:
- 宽松匹配:匹配指令码,但不包括操作数,例如 mov $0x10,%edi 只考虑 mov;
- 严格匹配:在宽松匹配的基础上,额外增加了操作数;
- 完全匹配:在严格匹配的基础上,额外增加前驱和后继块的宽松匹配。
如上三个策略的优先级是递增的。当有多个 BB 块匹配时,选择偏移(BB 块相对函数地址)最接近的。

如上图,BB 0x1 和 BB 0x13 分别满足宽松匹配和严格匹配的要求。
在 Infer 阶段,需要解决的原始问题就是:在一张由基本块 BB(basic block)组成的流程控制图 CFG(control flow graph)上,只有部分点和边是已知它的权重的,需要为其他未知点推导权重,同时推导出的结果,要尽可能接近我们已经采样到的权重。

2.4 Propeller
2.4.1 Propeller 介绍
Propeller 和 Bolt 的优化原理类似,均是通过改变二进制中的代码布局,一方面提升分支预测的准确率,另一方面使热度高的指令聚集在一起,从而提升 cache 命中率,最终加速二进制程序的执行。Propeller 与 Bolt 的差别在于优化阶段不同,Bolt 在链接后进行优化,而 Propeller 在链接期间进行优化。

如上图所示,Propeller 的执行流包括 4 个阶段。其关键点如下:
- 目标文件中增加了一个 bb address map section,其中存储了指令地址与 Basic Block 的映射关系,因此 perf 采样得到的指令的热度可以直接对应到 Basic Block 的热度;
- 基于 Basic Block 的热度,可以将 function 中的 Basic Block 划分为多个 cluster,从而将“热”的 Basic Block 和“冷”的 Basic Block 划分到不同的 cluster 中;
- 重链接过程将 function 中不同 cluster 布局到不同位置,将“热”的 cluster 聚集在一起。

2.4.2 Propeller vs Bolt

综合来看,相对于 Bolt,Propeller 具有稳定性高、优化速度快、可维护性高三大优势,更加值得推广部署。
2.4.3 Propeller 使用痛点
LLVM 中集成的 Propeller 对于源代码和编译参数的更改是零容忍的。实践表明,无论是细微的源码调整,还是编译参数的变动,都会显著削弱其优化效能,甚至产生负面优化效果,这一特性极大地制约了 Propeller 在实际生产环境中的应用推广。具体体现在以下两个关键问题:
-
过期 Profile 引发的优化失效问题
在实际生产场景中,使用的 Profile 文件通常基于过去一天甚至数天前的采样数据生成。在 Profile 更新周期内,业务代码可能会进行一些微小的迭代修改。由于 Propeller 对源代码的变动缺乏兼容性,即使是极微小的代码变更,也会导致其优化效果严重下降。此外,对于部分业务来说,频繁更新 profile 具有较高的成本,该问题直接导致 propeller 在这些业务上难以应用。
-
与 AutoFDO 协同优化的冲突问题
当使用优化前二进制的 Profile 文件,同时进行 AutoFDO 和 Propeller 优化时,会出现编译参数不一致的情况。由于 Propeller 对编译参数的变更异常敏感,这种参数变化会直接导致 Propeller 不仅无法提升性能,反而会削弱 AutoFDO 的优化效果。尽管可以通过“先部署 AutoFDO 优化版本重新采样生成新 Profile,再进行 Propeller 优化”的方案解决,但这一流程大幅增加了在线服务的构建复杂度和耗时,对于追求快速迭代和高效部署的业务场景而言,难以满足实际需求。
2.4.4 解决方法
Propeller 对于源代码和编译参数更改零容忍的原因在于其将 function 划分为 cluster 时,使用 Basic Block ID 来标识每个 cluster 中的 Basic Block,如下图所示。Basic Block ID 是非常不稳的,源代码的微小变化以及编译参数的变化均会导致 Basic Block ID 的剧烈变化,此时,再按照原本的 Basic Block ID 去划分 cluster 将导致大量错误的划分(“热”的 Basic Block 可能划分到“冷”的 cluster,“冷”的 Basic Block 也可能划分到“热”的 cluster),从而劣化优化效果。

为解决如上问题,我们从 Bolt 解决过期 Profile 的方法中受到启发,将 Bolt 中的 Match & Infer 技术引入 Propeller,如下图所示,通过 Match & Infer 解决 AutoFDO 优化之后的 Basic Block 和 Profile 中的 Basic Block 失配问题。

该方案的关键点具体如下:
- 使用 Basic Block Hash 来标识 Basic Block
Basic Block Hash 通过 Basic Block 中的指令以及 Basic Block 在 CFG 中的结构来计算,相对 Basic Block ID 更加稳定;
- 引入 Match 方法
通过 Basic Block Hash 来匹配 Basic Block,由于 Hash 值具有较强的稳定性,在源代码和编译参数的更改时也能实现较为精确的匹配;
- 引入 Inference 方法
由于 Match 方法不能完全避免失配,Inference 使用 BOLT 中介绍的网络流算法推断出所有 Basic Block 的权重,进一步提升了稳定性。
有了所有 Basic Block 的权重,就可以基于此调整 Function 的内部 Basic Block 布局以及“冷热”段的划分了。
2.4.5 成效
通过上述的方案,我们成功解决 Propeller 过期 profile 引起的优化失效,以及与 AutoFDO 协同优化冲突两大应用痛点。
下图中,展示了使用 clang 编译 clang 时原始 Propeller 和改进后的 Propeller 的性能收益随 profile 时间的变化曲线,可以看到原始的 Propeller 收益随 profile 时间推移迅速下降,使用一年以前的 profile,已经几乎没有性能收益,而改进后的 Propeller 随时间推移缓慢下降,即便使用一年以前的 profile 仍然能获取 8% 的收益。

图下图所示,展示了 Propeller 与 AutoFDO 叠加使用的收益情况。仅使用 AutoFDO 时,收益为 13.2%,叠加原始 Propeller 后收益却降到了 5.9%,即原始 Propeller 劣化了 AutoFDO 的收益,而使用改进后的 Propeller,收益为 15.6%,获得了进一步的收益叠加。

三、实际收益
目前编译优化已在公司内覆盖数百万的 CPU 核心数,整体带来 10% 左右的 CPU 相对收益,延迟降低 4%~8%,编译时间无显著增加。

借助 AutoFDO、ThinLTO、BOLT、Propeller 等编译优化技术,快手已成功地跨越了将复杂编译方案部署至生产环境中所面临的诸多难关。在此基础上,快手将持续深化相关领域的研究与实践,致力于基于这些技术改进与创新,为技术社区及整个行业积极贡献我们的智慧与力量。
如果你想了解更多关于编译优化 或 C++ 编译技术的深度讨论和实践经验,欢迎访问 云栈社区 ,与更多开发者交流。