性能优化很难,因为它本质上是一个暴力穷举的任务
purplesyringa
为什么性能优化如此困难
2025年4月29日 Hacker News Reddit 我这里不是在说技能、知识,或者说服一个专注于激进加速的世界优化是必要的。 性能优化之所以困难,是因为它本质上是一个暴力穷举的任务,你对此无能为力。
这篇文章有点像是我对代码优化感到沮丧的抱怨。 我也会尝试给出一些可行的建议,希望它们能提升你的体验。
可组合性
某些优化只能协同工作,而另一些优化组合在一起会导致性能恶化。 成为专家意味着知道存在哪些优化途径;成为大师意味着知道选择哪一种。
我正在写一篇关于整数格式化的文章,涵盖一种非常特殊的算法设计 —— 但我仍然没有完成它,因为有大约五种不同的选择要做,我不知道它们如何相互影响,而且我需要分析 25 个变体才能凭良心声称哪一个是最好的。 我的几个项目也同样陷入了困境,因为我没有意愿去实现十几种组合。
修剪“明显”次优的方法几乎只是一种启发式方法。 我喜欢认为我比大多数人更了解 x86-64 CPU,但它仍然时不时地让我感到惊讶。 由于向量化,愚蠢的算法可能变得更适用,聪明的代码可能会因为分支预测错误或存储到加载转发出错而失败。
优化需要大量的试错。 我不喜欢“直觉不起作用,分析你的代码”这句话,因为它似乎在说性能分析可以替代理论计算,但事实并非如此。 但我无法争辩说性能分析是可以避免的。 我经常开玩笑说 perf report
是我首选的反汇编器。
更糟糕的是,你也不能信任“明显”的好代码。 在之前的一篇文章中,我通过用一个超线性排序替换一个简单的线性遍历来优化它。 这绝不是一种独特的体验:就在昨天,我看到有人通过将数字除以 double
,然后对它们进行四舍五入,并从商中计算出余数,来优化一流的 Barrett reduction。 它是如此愚蠢,以至于它不可能工作,但它确实有效。
好消息是,这里的工作可以分配给多个尝试不同方法的人。 开源项目尤其受益于此,因为贡献者通常具有不同的优势并专注于不同的想法。 尝试通过咨询你的队友或阅读其他人在解决类似任务时的经验来重用工作。
连续性
这方面的一个变体是存在截止边界的算法。 你不再选择是否应用优化:你还需要通过更多的试错来选择参数。 例如:
- 由于高 big-O 常数,混合排序算法可以在不同的实现之间切换。
- FFT 可以在递归和迭代方法之间切换,以更好地利用处理器缓存。
- 根据数据密度,最佳集合结构可能是位集、哈希集或互补哈希集。
修改任何一种替代算法都需要重新基准测试以更新最佳边界。 由于与 CPU 缓存、分支和内存访问预测、递归截止的离散性质以及浮点精度(对于通过 FFT 进行大整数乘法)的交互,此处的微小修改可能会导致剧烈的最终性能变化。 忘记重新基准测试并放弃一种有前景的方法很容易使 2 倍的性能留在桌面上。
再举一个例子,考虑一个程序根据概率 p 执行 n 次动作 A 或 B。如果 p 远离 12,分支预测意味着最好使用 if
来实现切换;如果 p 接近 12,分支预测将失败,并且无分支方法会更好。 不仅 A 和 B 的相对性能在这里很重要,而且分支预测错误的成本也很重要,这可能不仅取决于 CPU,还取决于执行的确切代码。
理想情况下,你会有一个测试平台,它可以绘制图表并自动找到最佳参数值,即使让它工作起来可能会令人疲惫。 这样,一直运行检查就变得廉价且在情感上更容易。 即使需要半个小时,你仍然可以并行处理其他事情。
不兼容性
不兼容优化的最糟糕的例子是由于外部约束而失败的那些。
一个例子是当两个 LUT 不能同时放入缓存,但可以单独放入缓存。 你有时可以通过将计算分成多个通道来解决这个问题,其中每个通道只需要访问确实适合缓存的辅助数据。 这不一定意味着对所有数据进行两次传递,消耗 2 倍的内存带宽 —— 你可以对数据进行分块,并对一个块应用两次传递,如果该块适合 L3,则可以提高性能。 但有时这不起作用,然后我就会撞墙。
寄存器压力甚至更糟,因为这只是 ISA 的问题,而不是微架构的问题。 硬件有足够的寄存器,只是它们没有暴露给用户代码。 你可以尝试在通用寄存器和向量寄存器之间拆分数据,只要你很少跨越 GPR-SIMD 边界,这就可以工作,但在这一点上,你还不如改变你的职业。
不必这样。 FPGA 使你能够设计自己的硬件(某种程度上),并且像交互网络这样的替代方法有机会使软件指定的操作与通常在硬件中实现的操作一样最佳。 但这不是我们生活的世界,不,我们生活在 Intel 不断向 AVX-512 引入有用的指令,但后来又放弃它们的世界中,所以我需要在具有 vp2intersect
或具有 FP16 的 CPU 之间进行选择。 因此,你不仅要对不同的代码进行基准测试,还要在不同的 CPU 上对其进行测试,以决定将其部署在哪个 EC2 实例上。
我对此的唯一建议是尝试达到尽可能好的结果,即使它比理论上的最佳结果更差。 通过将一些计算移动到运行时来减小其中一个 LUT 的大小,用汇编重写一段代码以更好地管理寄存器,并且当所有其他方法都失败时,接受你必须做出选择。
编译器
“编译器比人类更聪明”是一种常见的说法。 这与事实相去甚远。 任何开发人员都可以看到以下两个代码段(应该)是等效的:
let condition1 = HashSet::from([a, b]).contains(&c);
let condition2 = a == c || b == c;
但是编译器不会将前者优化为后者(JVM 的 JIT,在某些情况下,除外)。 它们不以抽象的方式推理,而且它们肯定不会以你的辅助抽象的方式推理。 这不仅仅适用于高级代码:LLVM 甚至不理解按位 AND 是一个交集。
不,编译器擅长与优化不同的东西:它们将更高级的语言变成零成本抽象,但没有独创性。 编译器是最佳的 transpiler —— 除了一些例外,它们精确地对你在源代码中编写的内容进行代码生成。 它们允许你使用 Rust 或 C++ 的语法和功能编写汇编代码,但不要忘记你编写的 arr.map(|x| x / c)
将调用 idiv
而不执行明显的 libdivide-style 预计算。
有时我想知道是否应该将 -O2
重命名为 -fzero-cost-abstractions
。
这可能会让我听起来好像在争辩说编译器只擅长管道,但它们甚至不擅长这一点。 例如,它们在所有事情中可能在寄存器分配方面很糟糕。 如果一小段很少执行的代码需要许多寄存器,GCC 会通过在每次迭代时溢出热路径访问的变量来满足这一需求,而不仅仅是在进入冷路径时。 Clang 更好地处理了这个简单的示例,但在更复杂的情况下会失败。
教训是永远不要盲目信任编译器。 始终检查反汇编代码,咨询像 perf
这样的指令级分析器,并且不要害怕使用此信息来推动编译器做正确的事情(如果它能带来切实的改进)。
尽管有明显的缺点,编译器不允许你纠正它们做错的事情。 无法同时提供优化的汇编代码和等效的 C 代码,并让编译器在一般情况下使用前者,在特殊情况下使用后者。 自定义调用约定大多不受支持,选择无分支代码和有分支代码以及任何其他汇编技巧也是如此。 有内在函数,但 LLVM 和 rustc 仍然 试图变得聪明并重写它们,这有时会导致性能恶化,除了添加优化屏障之外别无选择。
e-graphs,正如 Cranelift 所推广的那样,试图解决这个问题,但据我所知,在这个领域还没有取得太大的成功。 但我仍然充满希望。
文档
对于 x86 处理器,uops.info 提供了每个指令和许多 Intel 和 AMD CPU 的时序和端口信息。 Agner Fog 编写了一本关于 x86 处理器优化的手册,并发布了他自己的表格。 Intel 软件开发人员手册 包含超过 5000 页,不仅记录了指令集,还记录了其 CPU 的许多内部工作原理。
Apple Silicon 没有任何类似的东西。 我不知道如何使用 M1+ 处理器。 有Apple Silicon CPU 优化指南,它只有 169 页,读起来像是人们会为新手写的东西,而不是专家。 它读起来像你可能在 HN 上找到的教程,而不是我感兴趣的东西。 它包含对某些指令类别的延迟和吞吐量的估计,但令人沮丧的是表格数据很少,并且它没有提及 uop fusion 或提供端口信息。 Dougall Johnson 的研究 非常有价值,但仅涵盖 M1,而不涵盖较新的 CPU,并且它仍然没有回答许多问题。
即使是 Apple 的 LLVM 分支 也缺少 Apple Silicon 的调度注释。 当 Apple 懒得调整他们自己的编译器时,我应该如何编写高效的代码? 为这样的平台优化代码是 90% 的逆向工程和 10% 的编写有意义的代码 —— 而编写有意义的代码已经很难了。
对此的正确解决方法是窃取知识产权,但我不允许这么说,所以我不会这么说。 哎呀。
结论
性能优化之所以困难,是因为你必须:
- 手动探索数十种情况而不会崩溃。
- 使用不充分的工具进行迭代。 (性能分析器和 MCA 很有用,但它们仍然是无法与底层复杂性相匹配的玩具。)
- ~~将正方形硬塞进圆孔直到它们合适。~~ 合并不兼容的优化。
- 处理企业贪婪和文化冷漠。
这绝非易事,但这仍然是我喜欢做的事情,即使人们经常认为任何低于根本性改进的事情都是浪费时间。 对我来说,10% 的优化是一种艺术形式,但不仅仅是这样。 小的改进会累积并有助于形成更好的用户体验,即使没有单一的优化本身看起来很有价值 —— 就像提高数据传输速率导致我们在处理和利用信息的方式上发生结构性变化一样。
优化节省时间,而时间是人们最缺乏的资源。