每周性能技巧 #79:一次最多只做一个权衡
本节阅读量:本文翻译自 Abseil 官网的 Performance Tip of the Week #79: Make at most one tradeoff at a time。
原文发布于 2024 年 1 月 19 日,作者 Chris Kennelly 和 Matt Kulukundis,更新于 2025 年 6 月 20 日。
开发并启用优化经常涉及权衡:用更多 RAM 换更少 CPU、选择立即解决哪些问题、延后哪些问题,等等。本篇讨论如何把项目拆成更小步骤,以提高速度,并最大化随时间积累的收益面积。
分步迁移:SwissTable
哈希表有许多显式和隐式属性,会影响它们的契约和行为。在设计 SwissTables 并规划迁移时,我们仔细选择在修改实现契约时延后、避免或有意识接受哪些权衡。
SwissTable 迁移主要关注迭代顺序,把其他几个有价值的变化延后。在 SwissMap 实现中,我们加入了故意随机化哈希表迭代顺序的代码,使未来改进不必再次处理依赖迭代顺序的脆弱测试。
由于 SwissTables 和 absl::Hash 都有随机化,我们后来可以对二者底层结构做多次优化:改变小尺寸行为、调整 windowing 方式,并完全替换核心哈希函数。所有这些优化都在发布后完成,因为过程中的每一步都是净收益。
提示:修复 Hyrum 定律问题时,寻找能故意扰动行为的方式,让未来变化更容易。
entry 的指针稳定性在 Hyrum 定律意义上非常可见。SwissTable 迁移有意延后这个选择,提供指针稳定变体 absl::node_hash_map,并把用户直接迁移过去。我们发布指南鼓励用户自行从 absl::node_hash_map 迁移到 absl::flat_hash_map,但自己的努力集中在让大家迈出第一步。
用户发现第二步迁移到 absl::flat_hash_map 明显更容易,因为 SwissTable 已经有随机化。
提示:稳定中间状态可以成为迁移的良好停止点,让进展不必一次跳到最终状态。即使不能立刻把人带到最终状态,也要在书面指南中明确最佳情况是什么。
开始 SwissTable 迁移时,我们知道很可能要在 Abseil 发布它,也想为它构建新的哈希框架,也就是现在的 absl::Hash。但这些还没准备好。与其等所有部件到位再发布,我们先开始 SwissTable 迁移,没有自定义哈希框架,后来再把命名从另一个 namespace 迁移到最终的 absl。
提示:早点发布可以更早获得收益,并从用户那里得到重要反馈。
absl::Hash 准备好后,我们确保它内置随机化,并把 SwissTable 默认 hasher 切换过去。由于它默认工作,并让客户更容易走上明亮路径,采用过程非常顺利。
提示:如果可以,优先切换默认值,而不是迁移代码。
引入用于 fleetwide 监控的哈希表 profiling时,一些用户惊讶于表可能被采样,从而触发额外系统调用。如果一开始就加入采样监控,迁移会多一类需要调试的问题。这也让我们可以为这个具体特性提供清晰 opt-out,而不拖延整个 rollout。
此外,做迁移的人不必同时调试多种失败类型,因此每次发布都可以处理得更快。
提示:把变化拆成不同发布,让每次调试只面对一类问题。
迭代改进:部署 TCMalloc 的 CPU cache
TCMalloc 最早引入时使用 per-thread cache,因此名字叫 Thread-Caching Malloc。随着线程数增加,per-thread cache 遇到两个增长中的问题:进程 cache 大小被分到越来越多线程,每个 cache 平均更小;闲置线程更多时,更多 RAM 事实上不可访问。
最初,Andrew Hunter 添加了 per-CPU cache 支持。实现不再给每个线程缓存内存,而是给机器上的每个物理核心一个 cache。如果某个线程被调度走,另一个线程可以复用同一个 cache。随着有机采用,per-CPU cache 用量达到 fleet CPU 使用量和内存分配的大约一半。
大量早期采用后,TCMalloc 默认值改变:除非另有要求,使用 per-CPU cache。由于 per-CPU cache 额外元数据,这用 RAM 换 CPU。我们没有完全消除这个成本,而是选择有意识接受该权衡。后续优化虽减少了 per-CPU cache 的 RAM 开销,但需要几年才出现,因此这个策略让我们提前多年获得增量收益。
提示:识别可以迭代落地改进的方式。这样优化可以在准备好时部署,而不需要预先完成所有预期研发。这有助于最大化收益曲线下面积。
多年后,典型服务器核心数大幅增加。per-CPU cache 使用按物理 CPU ID 索引的 cache 数组,因此需要分配更多元数据,尽管典型应用使用的核心数没有同比增长。并且,配置使用 16 个核心的 job 可能在 128 核 socket 上移动,导致这些核心上的 cache 都被填充,即使应用并不会主动运行在所有这些核心上。
这些观察推动了若干优化。TCMalloc 包含大量遥测,使我们能计算 per-vCPU cache 使用的内存量,从而估算潜在机会并测量最终影响。
提示:跟踪打算稍后优化的指标,即使不是立刻优化,也能帮助识别想法什么时候值得追求和优先处理。通过监控元数据内存使用量和活跃 cache 数量,我们能识别问题何时相对其他机会已经值得解决。
解耦发布:Limoncello
在高系统内存带宽使用下,关闭硬件 prefetcher 的实验显示 fleet 有显著性能改善。数据分析表明,多数工作负载广泛改善,但少数出现回退。
按函数粒度探索数据后,我们识别出 A/B 测试中回退最明显的函数。很多回退位于具有 streaming 访问模式的核心库。给它们添加软件 prefetch 后,我们在关闭硬件 prefetcher 时恢复了性能。
提示:某个维度或数据切片上的回退,有时可以成为新机会的种子。
这些 prefetch 主要在硬件 stream prefetcher 关闭时恢复性能,但硬件 prefetcher 打开时也有帮助。硬件 prefetcher 需要预热期来学习访问模式,而且不像高层 C++ 代码那样知道短 stream 何时停止 prefetch。软件 prefetch 可以避免预热期,并保持高精度。
关闭硬件 prefetcher 照亮了机会,但软件 prefetch 的即时预热和高精度意味着它们可以立刻部署,无需考虑硬件 prefetcher 是开还是关。
几处 prefetch 到位后,项目重新和之前看到回退的几个团队评估。得益于软件 prefetch,这些缺口大幅缩小。由于这些位置在少数被仔细研究且经常优化的核心库中,维护 prefetch 是可行的,即使跨多个硬件代际。
提示:独立发布变化可以避免不必要耦合,并简化 rollout 策略。
结语
优化机会的识别和发布方式会影响速度。让权衡尽可能简单直接,更好时完全避免权衡,可以让我们迭代改善性能而少遇阻碍。一路导出指标,可以在工作负载和硬件演进时识别最大机会。