未充分利用的性能
摘要
一篇技术博客文章,演示了如何通过LLVM的配置文件引导优化(PGO)在标准-O3和LTO之外显著提升二进制性能,以SQLite作为基准测试。
<p><a href="https://lobste.rs/s/7lrk2t/leaving_performance_on_table">评论</a></p>
查看缓存全文
缓存时间: 2026/05/29 09:54
# 把性能留在桌上
来源:https://fzakaria.com/2026/05/23/leaving-performance-on-the-table
我一直在`$DAYJOB$`中使用 LLVM,并且逐渐熟悉了优化工作负载的好处。
我通常认为优化二进制文件就是考虑是否给编译器标志加上了`-O3`;如果那天我特别厉害,可能还会加上`-flto`(链接时优化),然后就算完事了。
但事实证明,这其实留下了大量性能未被利用。
编译器默认假设每个分支被执行的几率相等,除非你使用了类似`[[likely]]`(参考链接:https://en.cppreference.com/cpp/language/attributes/likely)这样的提示。如果我们能向编译器提供更多关于工作负载通常执行路径的信息,那么它们就能生成性能更高的代码。
优化二进制文件主要有两种方式:插桩式(instrumented)或统计式(statistical)。当我们对二进制文件进行插桩时,我们会用一个插桩后的二进制文件运行工作负载,并捕获*实际执行的精确路径*。然后,我们会针对该工作负载完美调优地优化二进制文件。
如果我们的工作负载变化多样,我们可以通过`perf`收集一段时间的profile,然后根据调用图的统计出现情况创建一个优化的二进制文件。
两种方法都有各自的优点,不过我们先从插桩式开始,因为它更容易理解和跟踪。
让我们看一个非常简单的基准测试。我们将使用sqlite3(https://sqlite.org/)中的SQL来计算**斐波那契**。这是一个理想的工作负载,因为它完全是CPU密集型的,非常适合优化。
```
-- fibonacci.sql (1亿次迭代)
WITH RECURSIVE fibonacci(n, a, b) AS (
-- 种子值
SELECT 0, 0, 1
UNION ALL
-- 循环100,000,000次。
-- 取模运算使整数安全保持在64位范围内。
SELECT n + 1, b, (a + b) % 1000000007
FROM fibonacci
WHERE n < 100000000
)
-- 只输出最终计算值,避免终端I/O瓶颈
SELECT a FROM fibonacci WHERE n = 100000000;
```
我们将通过下载源代码来编译`sqlite3`。
```
> wget https://sqlite.org/2026/sqlite-amalgamation-3530100.zip
> unzip sqlite-amalgamation-3530100.zip
```
我们可以编译一个“传统”的优化二进制文件,仅使用`-O3`,再编译一个启用LTO的版本,因为我也想看看LTO本身能带来多少提升。
```
> clang -O3 shell.c sqlite3.c -o sqlite3_base
> clang -O3 -flto shell.c sqlite3.c -o sqlite3_lto
# 先运行一下看看大概时间
> time ./sqlite3_base :memory: < ./fibbonoci.sql
╭───────────╮
│ a │
╞═══════════╡
│ 908460138 │
╰───────────╯
________________________________________________________
Executed in 14.22 secs fish external
usr time 14.16 secs 0.00 micros 14.16 secs
sys time 0.00 secs 802.00 micros 0.00 secs
```
好的,看来我们的程序大约需要14-15秒运行。听起来还行?我们还能做得更好……🤔
接下来,我们再次编译程序,但这次*对二进制文件进行插桩*,这实际上是在程序中注入计数器来统计函数的调用次数。我们可以获得非常精确的调用计数,但二进制文件本身现在运行得慢得多,如果你的工作负载已经很慢,这可能是个问题。幸运的是,我们的时间范围(~15秒)还可以接受。
在得到插桩二进制文件后,我们再次运行工作负载以生成profile数据,然后使用该数据重新构建二进制文件。
```
# 1. 为Clang构建插桩版本
> clang -O3 -flto -fprofile-generate=. shell.c sqlite3.c -o sqlite3_instr
# 2. 运行1亿次斐波那契循环以生成Clang profile数据
> ./sqlite3_instr :memory: < ../fibonacci.sql
# 3. 合并原始profile
> ./llvm-profdata merge -output=sqlite3.profdata *.profraw
# 4. 构建PGO优化的二进制文件,但保留BOLT所需的重定位(-Wl,-q)
> clang -O3 -flto -fprofile-use=sqlite3.profdata -Wl,-q shell.c sqlite3.c -o sqlite3_pgo
```
最后一步是用BOLT进行优化。BOLT是一个后链接优化器,它需要我们保留重定位,所以我加上了`-Wl,-q`。
当我们用最终优化后的二进制文件运行工作负载时,已经能看到巨大的改善了!🤯
```
> time ./sqlite3_pgo :memory: < ./fibbonoci.sql
╭───────────╮
│ a │
╞═══════════╡
│ 908460138 │
╰───────────╯
________________________________________________________
Executed in 10.86 secs fish external
usr time 10.83 secs 0.00 micros 10.83 secs
sys time 0.00 secs 770.00 micros 0.00 secs
```
我们将工作负载时间缩短到了大约10秒,这几乎是**1.5倍**的提升。
现在,让我们用LLVM的BOLT(https://github.com/llvm/llvm-project/blob/main/bolt/README.md)来优化最终的二进制文件。BOLT是一个专为“大型应用程序”设计的后链接优化器。这意味着,它主要通过重新排列二进制文件中的代码,使具有高时间局部性的代码路径彼此靠近(空间局部性)。例如,这可以对性能产生积极影响,因为指令缓存会更有效。
```
# 1. 用BOLT对PGO二进制文件进行插桩
> llvm-bolt sqlite3_pgo -o sqlite3_bolt_instr --instrument --instrumentation-file=bolt.fdata
# 2. 再次运行工作负载以跟踪物理执行路径
> ./sqlite3_bolt_instr :memory: < ../fibonacci.sql
# 3. 在PGO二进制文件之上应用最终的BOLT优化
> llvm-bolt sqlite3_pgo -o sqlite3_ultimate \
-data=bolt.fdata \
-reorder-blocks=ext-tsp \
-reorder-functions=hfsort+ \
-split-functions \
-dyno-stats
> time ./sqlite3_ultimate :memory: < ./fibbonoci.sql
╭───────────╮
│ a │
╞═══════════╡
│ 908460138 │
╰───────────╯
________________________________________________________
Executed in 10.52 secs fish external
usr time 10.46 secs 591.00 micros 10.46 secs
sys time 0.01 secs 244.00 micros 0.01 secs
```
看起来快了一点,但不多。这很合理,因为`sqlite3`本身是一个很小的二进制文件(约6MB),但跑一遍还是有好处的。
用`hyperfine`运行一个更*全面*的基准测试,我们可以得到最终的结果汇总。
| 构建配置 | 平均时间 ± σ | 最小 ... 最大 | 相对速度(vs 最快) |
| :--- | :--- | :--- | :--- |
| PGO+BOLT | 10.536 s ± 0.051 s | 10.491 s ... 10.631 s | 1.00 |
| PGO | 10.805 s ± 0.055 s | 10.733 s ... 10.889 s | 1.03 ± 0.01 |
| LTO | 14.252 s ± 0.026 s | 14.225 s ... 14.315 s | 1.35 ± 0.01 |
| 基础(无LTO) | 14.292 s ± 0.071 s | 14.224 s ... 14.435 s | 1.36 ± 0.01 |
| Fedora包 | 14.496 s ± 0.074 s | 14.402 s ... 14.662 s | 1.38 ± 0.01 |
看起来我直接从Fedora生态系统获取的`sqlite3`是最*慢*的。当所有优化都应用后,我能够达到比现有版本最多**1.38倍**的速度提升。
对于代码庞大且高度多变的代码库,这些优化带来的效果会更加显著。
另外,不用担心让profile完美匹配你的工作负载。我有一位同事经常说,即使是不完美的profile,也远比完全没有profile要好得多。
相似文章
主机调优GCC以加快编译速度
这篇博客文章介绍了如何使用配置文件引导优化、LTO和-O3构建主机调优的GCC编译器,以实现更快的编译速度,并附有详细的说明和基准测试。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
Elixir 应用优化之旅
一位开发者分享了优化 Elixir 应用的经验与教训,重点介绍了针对 Postgres 连接池工具 Ultravisor 的性能改进。文章涵盖了使用火焰图、调用追踪等性能分析技术,以及 eFlambè 和 tprof 等工具。
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。
过早优化有时也挺有趣
一篇探讨为存储ping时间戳优化环形缓冲区数据结构的博客文章,讨论了标签联合、位域和结构体填充以减少内存占用。