每周性能技巧 #72:优化“优化”这件事
本节阅读量:本文翻译自 Abseil 官网的 Performance Tip of the Week #72: Optimizing optimization。
原文发布于 2023 年 8 月 7 日,作者 Chris Kennelly,更新于 2025 年 8 月 23 日。
随着摩尔定律放缓,我们不能再期待硬件创新免费带来性能提升,寻找优化机会变得越来越关键。本篇讨论如何选择、规划并推进优化项目。
规划
“计划没有价值,但规划至关重要。”
效率项目试图最大化硬件生产力,而项目本身也能通过有效规划来优化。项目选择会极大影响结果和影响力。
一个项目的实际影响,是多个因素的乘积。刚开始思考某个优化时,我们会估算所有这些因素。估算正确当然令人满意,但主要目标只是获得足够信息来做出更好决策,也就是优先做预期 ROI 更高的优化。
很多时候,一个有效数字就够了。花更多时间做更好估算或收集更多数据,本身不会提高效率。新信息到来时,我们可以更新先验。
确定要工作的领域后,就可以思考如何解决其中的问题。
最大潜在收益是什么
例如,如果一个函数只使用了整座仓库级计算机中的单个 CPU 核心,我们优化它的空间就有限。即使把成本降到零,影响也很低。相比之下,不去做更重要领域的机会成本很高。
我们能改变多少
只观察到“X 很昂贵”并不够,我们还必须能改变 X。
“光速”分析可以提供有用上界。如果一个操作必须从内存读取数据、处理并写回,那么它很难显著快过 memcpy。性能不必最终收敛到粗略类比对象,但如果已经很接近,就很难有意义地影响 X。
有些操作已经简化到极限,例如两个整数相加很难再加速。这时可能需要向栈上层看,减少相加次数,才能获得收益。
我们能否测量变化,以及如何测量
一个没有被测量的优化,就像森林里没人听见的倒树。
有些变化会产生高度非局部的效果,使准确测量更困难。TCMalloc 有一个“昂贵”的 prefetch,让 TCMalloc 看起来更贵,但会改善应用整体性能。在分布式系统中,客户端改变发送给服务器的请求,可能显著影响服务器成本,却不一定影响客户端看到的指标。
成功概率多高
修改一个小实现细节可能很直接,也可能揭露依赖现有怪癖的 Hyrum 定律问题。一个全新 API可能容易实现,却难以推动采用。如果别人以前看过同一个问题,也许这次情况不同,但我们应该控制乐观程度。
需要多久
估算应包括初步分析、实现、代码评审、调试、发布、测量,以及发布后的改进期。
我们希望节省的资源成本是时间投入的数倍,这既保证正投资回报,也为估算误差留出余地。
能持续多久
优化会存在多年不变的代码,是更安全的下注。如果目标系统很快会被替换,就需要考虑工作的预期寿命。
有时,最好放下这个想法,继续处理优先级列表中的下一项。另一些时候,优化工作可以迁移,或提高新系统的效率基线,使经济影响超过原始实现寿命。
站在巨人肩上
“伟大的艺术家会偷师。”
很多机会来自扩大既有优化的采用范围,或增强既有优化。
- 历史上,“hugepage text” 只在 fleet 中一部分服务器上用于减少 iTLB miss。对剩余服务器来说,获得应用生产效率提升只差打开一个 flag。许多规划因素已经降低风险:我们知道启用后会对 fleet 很大一部分产生可观影响,因此可以专注如何到达那里。
- AutoFDO 使用生产 profile 反馈给编译器以优化程序性能。初始基础设施建好后,我们通过多路策略继续节省:扩大采用、提高 profile 质量,并在其上构建 FS-AFDO、cmov-vs-branch 和 function splitting 等优化。
从其他领域借想法,即使看似简单,也可能很有价值。“不要做额外工作”听起来显然,但用新鲜视角看问题并交叉传播想法,往往很有效。
有了要尝试的想法后,我们就该系统化评估它们。
优化生命周期
“如果它和实验不一致,那就是错的。这个简单陈述就是科学的关键。”
降低想法风险
在初始规划中,我们做了一些假设来估算项目可能的投资回报。第一优先级是通过原型和评估来降低这些假设的风险。
- 用 benchmark 改进估算。这让我们能迭代改善估算,并在需要时投入更多精力获得更高保真度。
- 评估对次级指标的影响。即使无法立即看到端到端、全 fleet 影响,PMU 事件、profile 或单个应用也可以告诉我们方向是否正确。
- 先偷懒。做简化假设或绕过边界情况,可以更早拿到数据。即使还没有到最佳位置,也能确认关于机会的直觉是否有效。这也有助于区分真正关键的项目依赖和只是锦上添花的依赖。
如果尝试一个想法却无法移动预期指标,假设可能是错的。我们可能发现某事其实无法优化,或并不在预期关键路径上。这些解释可以帮助我们调整计划、直接清掉项目,或启发未来调查方向。
一旦证明手头有可行优化,就可以关注如何落地。
构建想法
测试失败可以指出约束实现的边界情况。这可能要求调整实现以保留行为,或清理不再允许的用法。选择主要依赖判断:更简单实现长期更容易继续优化,但过于艰巨的清理可能让任何东西都无法落地。
这也可能是加入随机化和其他防御的好时机,让这个领域未来的优化更顺畅。
借助 benchmark,我们可以微调想法,尝试不同参数,并确认它在多种情境下提升性能。
- 不必找到精确最优参数,因为我们很容易过拟合模型或负载测试。最优性可能只是幻觉。
- 对库做初始改动通常最难,尤其是继续推进时,会发现未知的未知。在真正克服启用障碍之前,优化每个周期或每个字节 RAM 都不重要。
例如,从 GoodFastHash 和 std::hash 迁移到 absl::Hash 改善了性能,但需要通过随机化哈希算法和识别脆弱测试来驯服 Hyrum 定律。克服这些问题后,我们才能继续迭代,把 absl::Hash 的算法换成另一个更高效的实现。
发布
生产最终才重要,我们的目标是把优化带到那里评估。发布并迭代优化有几种帮助:
- 更早交付价值。随着时间推移的效率曲线下面积,会因多次小型连续落地而变大,而不是等一个“更好”的优化一次完成。TCMalloc 的 Temeraire 优化在后续几年中影响翻倍,但早发布比等待几年后一次性完成更早带来收益。
- 实际表现可能和预期不同,无论好坏。这会启发后续寻找想法的方向。
对广泛启用的优化,opt-out 可以告诉我们哪些边界情况真正重要,且需要更多关注,从而产生进一步优化洞见。例如,Temeraire 的一个长期 opt-out 启发了两个不同优化。
一个领域的成功会带来交叉传播机会。我们可以把同一个算法或数据结构应用到相关但不同的领域。如果没有最初落地,可能永远看不到这种机会。
- 情况一直在变。项目启动时的假设,几年后可能已经失效。中间里程碑让我们能一路验证假设。
- 为了速度,我们可能绕过了非关键依赖。当这些依赖准备好后,可以重新审视选择,增强优化。
落地
优化发布后,我们希望正式落地,因此必须测量它。我们主要关注主指标,例如每 CPU 查询量上升。次级代理指标,例如某函数 CPU 时间、cache 或 TLB miss 等 PMU 事件,则帮助确认优化产生了预期效果。
这能把结果和巧合区分开来:我们是真的加速了某些东西,还是其他地方的改动碰巧让应用更快?估算用于指导开始方向,但我们也不希望这些预期偏置测量。真实数据可能比预想更好或更差,现实才重要。
总结
项目选择和项目执行都会极大影响优化结果。审慎选择挖掘领域,并采用“发布并迭代”的策略,可以释放显著节省。