未充分利用的性能

Lobsters Hottest 工具

摘要

一篇技术博客文章,演示了如何通过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以加快编译速度

Lobsters Hottest

这篇博客文章介绍了如何使用配置文件引导优化、LTO和-O3构建主机调优的GCC编译器,以实现更快的编译速度,并附有详细的说明和基准测试。

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

Hacker News Top

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

Elixir 应用优化之旅

Lobsters Hottest

一位开发者分享了优化 Elixir 应用的经验与教训,重点介绍了针对 Postgres 连接池工具 Ultravisor 的性能改进。文章涵盖了使用火焰图、调用追踪等性能分析技术,以及 eFlambè 和 tprof 等工具。

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。

过早优化有时也挺有趣

Lobsters Hottest

一篇探讨为存储ping时间戳优化环形缓冲区数据结构的博客文章,讨论了标签联合、位域和结构体填充以减少内存占用。