PGO 还是不 PGO,这是个难题。第 2 步

Hacker News Top 新闻

摘要

这是 Percona 三篇系列文章中的第二篇,介绍基于性能剖析的优化(PGO)的工作原理:插桩式两遍构建、控制流图中各条边的计数器数据收集,以及编译器如何利用这些数据来指导内联、分支布局、函数冷热拆分和指令缓存局部性优化。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/16 22:42

# 使用 PGO 还是不使用 PGO,这是个难题。第 2 步 来源:https://www.percona.com/blog/pgo-or-not-pgo-this-is-the-dilemma-step-2/ ## 第 2 步:工作原理 考虑到主题的复杂性和本文的篇幅,我将其拆分为 3 篇博文: 1. 什么是 PGO(https://www.percona.com/blog/pgo-or-not-pgo-this-is-the-dilemma-step-1) 2. PGO 如何工作 3. 为什么我用 sysbench 训练的构建版本反而更慢,以及如何正确操作。(https://www.percona.com/blog/pgo-or-not-pgo-this-is-the-dilemma-step-3) **PGO 的工作原理:**PGO 采用两遍构建。第一遍编译时加入插桩(*\-fprofile\-generate*):每个基本块和分支都会配备一个计数器。运行训练工作负载后,计数器数据会转储到 *profraw* 文件中。第二遍重新编译,利用这些计数来指导内联决策、分支布局(热路径顺序执行,冷路径跳转到别处)、函数冷热拆分、面向指令缓存/iTLB 局部性的代码排序、循环展开,以及间接调用提升。关键在于,PGO 的目的不是“让训练工作负载运行得更快”,而是“告诉编译器哪些代码是热的、哪些是冷的,让它据此重塑整个二进制文件”。 ### 但它究竟是如何工作的? **阶段 1:实际记录了什么。**插桩后的二进制文件包含编译时注入的计数器,控制流图中的每条边都有一个,而不只是每个函数一个。因此,对于每个分支、每条循环回边、每个调用点,都有一个计数器,每当执行经过该路径时就会递增。这比“函数 X 被调用了 N 次”更精细,它记录的是“执行到这个分支时,我们向左走了 950,000 次,向右走了 50 次”。正是这种逐边的粒度,让第二阶段能够做出精确调整,而不只是判断“函数 X 是热的,函数 Y 是冷的”。 **阶段 2:编译器如何利用这些计数。**多种不同的变换都由同一组计数器数据驱动: *内联。*通常,编译器会根据静态启发式规则进行内联: - 函数大小、调用点数量 - 预估的成本/收益。 有了性能剖析数据,它就可以突破这些启发式规则:对于执行数百万次的调用点,即使按照静态成本规则来看“代价过高”,也会进行内联,因为运行时收益显然超过了代码体积增加的成本。 如果一个调用点在技术上可以内联,但位于极少执行的冷代码中,就会保留为真正的函数调用,因为将其内联只会让二进制文件膨胀,而没有任何收益。 *分支布局。*代码中的每个“if”都会被编译为一条分支指令,存在两种可能的结果: - 直接继续执行下一条指令 - 跳转到其他位置。 顺序执行基本没有额外开销;CPU 已经在按顺序取指,因此不需要付出额外成本。跳转则有开销:CPU 必须提前预测分支走向,才能继续预取指令;如果预测错误,就必须丢弃已经排入流水线的工作,从正确的位置重新开始。 这就是“流水线气泡”,也就是一次短暂的停顿。因此,“顺序执行”的路径成本较低,而“跳转到别处”的路径在预测错误时会付出代价。 编译器会将热路径安排为顺序执行路径,并把冷路径移到主执行序列之外;也就是将其实际搬到二进制文件中的另一个位置,通常放入 .text.unlikely 节。因此,像 if \(unlikely\_error\_condition\) \{ ...罕见情况处理... \} 这样的代码块,就不再夹在热路径中打断执行;它会被整体移到别处,热路径则变为一段没有岔路的连续指令。 *需要明确的是,调整源代码中 if/else 的顺序,通常不会改变编译器对机器码的布局。优化编译器会根据性能剖析数据(PGO)或静态启发式规则自行决定分支布局,而不是根据我们在源代码中先写了哪个分支。因此,手动交换 if 代码块的顺序,通常对编译结果影响甚微,甚至毫无影响。* *函数冷热拆分。*这是将同样的思路应用到单个函数内部。一个函数可能包含一个频繁执行的核心循环,以及一段很少触发的错误处理尾部代码。编译器会将该函数在物理布局上拆成两部分: - 热部分保留在 .text.hot 中 - 冷部分移到 .text.unlikely 中。 函数的行为仍然完全相同(需要时通过跳转连接两部分),但现在热部分更小、更紧凑,因此一条指令缓存行能够容纳更多热代码,而几乎从不执行的冷代码也不会因为紧挨着热代码而浪费指令缓存空间。 *整个二进制文件的函数重排。*到了这里,“重塑整个二进制文件”就成了字面意义上的操作。在链接时(尤其是在启用 LTO 的情况下,而 MySQL 的 PGO 构建会启用它),最终可执行文件中的函数会在物理布局上重新排序,使频繁相互调用的函数,或在热点工作负载中依次执行的函数,在内存中彼此靠近。这能够最大限度地提高指令缓存和 iTLB 的局部性;CPU 的取指单元会读取一组紧密排列的热函数,而不是在一个 100 多 MB 的二进制文件中四处跳转。对于 mysqld 这样规模的程序,**这通常是收益最大的一项优化**,因为普通构建会**按照源文件碰巧被编译的顺序来排列函数,而这个顺序与运行时的调用模式毫无关系**。 *间接调用提升。*如果性能剖析数据显示,某个虚函数调用或函数指针调用在绝大多数情况下都指向同一个目标(这在使用虚函数表的 C\+\+ 中很常见,例如基本上只注册了 InnoDB 的存储引擎接口),编译器就会插入带条件检查的直接调用:“如果目标 == 这个特定地址,就直接调用它,跳过间接跳转;否则退回到间接调用。”与通过表进行间接跳转相比,直接调用的成本更低,也更容易被分支预测器预测。 *寄存器分配与代码密度的权衡。*编译热代码时会优先考虑速度,更积极地展开循环,并为热路径中的值分配更多寄存器。编译冷代码时,尤其是在使用 \-fprofile\-partial\-training 并对冷路径进行特殊处理时,则会优先考虑体积,使用更少的寄存器、减少循环展开,因为这些代码几乎不会执行。因此,其运行时成本无关紧要,但它在二进制文件中占据的空间并非没有代价(它仍会占用磁盘/页缓存空间,而且如果放置不当,还可能将热缓存行逐出缓存,这正是它会被单独放入 .text.unlikely,而不是仅仅不加优化地留在原处的原因)。 *switch/跳转表的底层实现选择。*一个包含许多 case 的 switch 语句,可以被编译为跳转表(速度快,复杂度为 O\(1\),但需要一次完整的表加载和间接跳转),也可以被编译为一连串比较(逐个 case 判断时较慢,但如果某个 case 占主导,分支预测效果会更好)。性能剖析数据会告诉编译器,实际运行中究竟哪个 case 占主导,并据此做出选择。 **基于以上内容**,“重塑整个二进制文件”并不是比喻。编译器会重新绘制可执行文件中机器码的物理布局:哪些指令彼此相邻,哪些代码段是热的、应紧密排列,哪些是冷的、应放到一旁,哪些调用是直接调用、哪些是间接调用,以及 CPU 的取指和预测资源应该用在哪里。所有这些都不要求训练期间处理的*值*与生产流量相似。它只要求控制流的*形态*相似,也就是频繁执行的分支、函数和路径与生产流量相似。这正是 MTR 虽然使用不同数据、但凭借广泛覆盖仍能让优化效果良好迁移的根本原因:它会遍历 mysqld 中几乎所有的代码路径,即使实际查询和数据与 TPCC 工作负载完全不同,而控制流形态恰恰就是 PGO 的优化对象。 为什么我用 sysbench 训练的构建版本反而更慢,以及如何正确操作。(https://www.percona.com/blog/pgo-or-not-pgo-this-is-the-dilemma-step-3)

相似文章

未充分利用的性能

Lobsters Hottest

一篇技术博客文章,演示了如何通过LLVM的配置文件引导优化(PGO)在标准-O3和LTO之外显著提升二进制性能,以SQLite作为基准测试。

Go 中的性能剖析引导优化

Lobsters Hottest

Daniel Lemire 对 Go 中的性能剖析引导优化(PGO)进行了基准测试,显示在解析 JSON 时速度提升约为 2-5%,当训练 profile 与工作负载匹配时效果最佳。

为AI代理构建性能分析引导优化

Reddit r/AI_Agents

作者正在开发Agent-PGO,这是一个工具,可以分析AI代理的执行,动态地为不太关键的任务替换更便宜的模型,同时通过评估基准保持质量。

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。