每周性能技巧 #97:良性生态循环
本节阅读量:本文翻译自 Abseil 官网的 Performance Tip of the Week #97: Virtuous ecosystem cycles。
原文发布于 2025 年 8 月 21 日,作者 Chris Kennelly,更新于 2025 年 11 月 27 日。
软件生态系统的目标,是在最大化效率、正确性、可靠性等品质的同时,尽量降低获得这些品质所需的成本。通过定制化改进某个单一服务,可以更快构建某个优化,但这种做法的收益上限天然有限,并且会增加技术债。点状方案无法带来横向应用功能时的完整收益。
这一篇讨论的是:与单个应用合作获得的优化经验,如何进一步变成让所有人受益的效率提升。
案例研究
下面看几个案例:我们如何把原本只对单个团队有收益的特性,推广为让整个 Google 受益的改进。
SwissMap
Abseil 的哈希表实现 SwissMap,源于 C++ 核心库团队与搜索索引团队两位苏黎世工程师 Alkis Evlogimenos 和 Roman Perepelitsa 的合作。他们原本想做一种改进的哈希表:使用开放寻址来减少数据间接访问,改进设计以降低内存使用,并添加 API 特性以避免不必要的复制。
Jeff Dean 和 Sanjay Ghemawat 建议使用 SIMD 加速的控制块来加快比较,使哈希表可以在更高负载因子下高效运行。
哈希表是程序员工具箱中的关键工具,但它跨 API 边界传播的程度,通常远不如 std::string 和 std::vector 这类词汇类型。对索引团队来说,只替换应用中最热的哈希表,就已经能获得大部分收益。
但团队没有止步于此,而是选择推动更优秀哈希表的广泛采用。数万次改动之后,Google 代码库中几乎每个哈希表都是 SwissMap。推广早期就节省了大量 CPU 和 RAM。这些节省只有在我们追求规模化迁移时才得以释放。
广泛推广带来了持续的生态收益:
- “直接用 SwissMap” 是个好建议;但对正在开发新功能的工程师而言,使用现有类型更容易,即便 SwissMap 在性能上更合适。SwissMap 的广泛使用,让新代码更容易自然选择它。
- 借鉴以往哈希表改动的经验,我们引入了随机化来防止代码依赖迭代顺序。这让后续底层优化更容易落地,也让我们可以通过更改哈希算法来迭代改进哈希函数。
- 由于实现自由度更高,我们能够添加跨整个 fleet 生效的遥测特性,例如内置 profiling。这让我们能够发现并修复糟糕的哈希函数,识别小表优化机会,并主动将容器
reserve到最终大小。
由大小类别驱动的分配
TCMalloc 这类现代内存分配器使用“大小类别”来高效缓存小对象。分配器不会精确适配每次分配,而是确定请求落入哪个大小类别,并检查该大小类别的 freelist。当对象释放时,分配器确定内存属于哪个大小类别,并把对象放入相应 freelist,以便未来复用。这种设计简化了记账,并降低了分配和释放内存的成本。
Sized deallocation
使用大小类别要求内存分配器在释放对象时判断应放入哪个 freelist。TCMalloc 最初的映射方式需要多次指针解引用。2012 年,Google 编译器优化团队发现,对象大小通常在分配器外部已经可知。把该大小作为参数传给分配器,可以避免昂贵查找。
实现 sized deallocation 后,多个团队能够采用该特性来降低分配成本。这项优化可能会放大既有未定义行为;在少见的尾部填充结构场景中,也可能引入 bug,要求采用者清理自己的代码和传递依赖。由于这些潜在问题,默认行为仍然保持为 “unsized” delete。
在全 fleet profile 显示 TCMalloc 横向成本的推动下,我们开始为整个代码库启用 sized deallocation。
- 向 TCMalloc 添加运行时断言,以便在 debug 构建中捕获 bug。
- 调查测试失败,并提交大量修复。
- 逐步将该特性推广到 Address Sanitizer、debug 和 optimized 构建,防止回退。
基础设施改进,例如运行时断言本身,为单个团队开发和为广泛部署开发的成本相同。对于其他步骤,例如在测试中启用 sized deallocation,集中操作所有测试,比要求每个团队为自己和依赖项搭建一套并行测试更便宜也更容易。
虽然该项目达成了降低 fleet 中 TCMalloc 成本的原始目标,但集中投入还通过创建更坚固的基础,增强了整个生态系统的可靠性:
- 通过为所有测试启用 sized deallocation,已经依赖该优化的团队可以防止传递依赖中出现新的 bug。过去,这类 bug 会表现为生产崩溃,迫使团队像打地鼠一样额外设置测试。将整个平台迁移到 sized delete,关闭了整个 bug 类别,同时避免了重复和临时测试设置的成本。
- 额外信息让 TCMalloc 可以偶尔复查声称的大小,从而发现更多内存破坏 bug。只有在大小参数常规传递给释放操作时,这才成为可能。
- 集中清理发现了一种相对少见但重要的模式:带尾部填充的结构不能很好配合 sized deallocation。这推动了 C++20 的 destroying delete 特性。如果没有中心化视角看到它的价值,该特性可能不会被标准化并普遍采用。
如果我们避开集中采用,单个团队会面临相似挑战、更多生产 bug,也无法从后续改进的飞轮中受益。
大小类别改进
大小类别简化了分配和释放内存所需的逻辑,但也引入了新的优化问题:我们应该选择哪些大小作为 bucket?多年来,这些划分是基于全 fleet profile 来选择的,目标是最小化请求向上取整到所属 bucket 时损失的内存(“内部碎片”)。
内部碎片只是我们做选择时可以观察的一个指标。合并两个大小类别意味着某些较小请求会变大,增加取整开销;但它也会减少峰值需求时留在 TCMalloc 缓存中的内存量(“外部碎片”)。大小类别太少也会给 TCMalloc 后端和全局锁带来更多压力。
一次与 Search retrieval 团队的合作产生了一系列优化。实验显示,两组大小类别 “64 以下为 2 的幂” 和 “仅 2 的幂” 都能改善性能。我们没有只停留在那个服务上,而是选择评估它们对所有工作负载的影响。
前者在最大化 CPU 性能和适度增加 RAM 使用之间取得了折中。全 fleet A/B 实验让这组配置可以被评估并推广,用于改善整体 CPU 性能;结果与最初推动这项工作的负载测试相当。
继续研究“仅 2 的幂”的结果后,我们发现方向是减少对象在 TCMalloc 某个缓存上停留、等待下次使用它的分配的时间。通过选择可能具有高分配速率的大小类别,而不是只最小化内部碎片,我们进一步改善了全 fleet CPU 性能。
可以把这与一个假设反事实对比:如果我们把大小类别可定制性做成 TCMalloc 的完整特性,让各个团队根据自己的工作负载调优,会怎样?
- 对于有时间也有意愿定制配置的团队,它们可能会获得比全局推广更大的局部收益;但随着时间推移,这些设置可能会过时。第二轮全局优化凸显了风险:单个团队可能会停在次优点。
- 更多配置会让 TCMalloc 更难维护,并阻碍实现自由度。
- 严格控制的配置空间使中心化改进可以落地,而不必绕过既有定制,或承担破坏某些用例的风险。
Turbo 的麻烦
早期尝试在通用工作负载中使用 AVX 指令时,遇到了第一代支持 AVX 的平台会降频的逆风。在数学密集型工作负载中,用适度降频换取浮点吞吐量翻倍是可以接受的折中。但在其他工作负载中,编译器偶尔在主要是标量的代码中使用这些指令,会带来性能回退而不是收益,这阻碍了采用。
修改编译器以限制自动向量化打破了僵局。该改动之所以可行,是因为:
- 大多数数学密集型代码已经使用 intrinsics 来确保高质量向量化,而不是依赖自动向量化。编译器改动不会影响这些代码。
- 标量代码可以解锁并利用后续微架构中的 BMI2 等新指令集。
- 在主要是标量的代码可以被某种程度向量化的地方,编译器会被限制,不会引入会导致降频的重量级向量指令。
能够利用这些新指令的性能收益而没有副作用,推动了更广泛的采用,并为进一步构建横向和纵向驱动的优化创造了飞轮。
经验
不要假设现状固定不变
只处理单个应用的团队,有时会把底层生态和平台视为不可变,并在这个约束内解决问题。但如果生态级改动有足够价值,平台本身很少真的不可变。
即使出于速度考虑绕过问题是合理的,把这些痛点暴露出来也可以提高人们对常见痛点的认知。如果每个人都绕开一个问题,而不是看到它在平台层面被一次性修复,那么一个频繁问题可能看起来像罕见问题。
关注问题和结果,而不是精确方案
原型是评估如何解决问题、判断哪些策略真正有效的宝贵资源。退一步思考需求和期望结果会很有帮助。我们可以同时说:“是的,这是一个值得解决的重要问题”,以及“但应该用另一种方式解决”。
在为原型规划规模化采用路径时,我们可能希望使用不同的实现策略,以覆盖更广的可触达市场、利用现有用例,或避免长期技术债。“精确部署这个原型”很少是我们真正关心的结果,因此对不同解决方式保持灵活,可以让影响力变大。
创造并使用杠杆
当我们把受纵向启发的方案横向部署时,通常是在寻找杠杆。如果已经存在广泛使用的库或特性可以改进,我们获得的收益会大于从头创造一个还需要被采用的新东西。与其绕过问题,不如看看能否把改动推到更底层,以更通用地、为所有人修复问题。
有时,我们需要自己创造杠杆。正如 SwissMap 和 AVX 所示,推动采用帮助我们启动飞轮:它在原本没有用量的地方创造用量,然后用这个杠杆推动后续中心化优化。
理解看得见和看不见的东西
带有明确主张的库可能提供相对较少的配置选项,看起来像是优化障碍;但如果过度迎合特定用例或灵活性,也很容易忽略机会成本。前者的成本往往可见,后者则不然。
更健康、更可持续的核心库,可以交付其他优化,帮助我们关心的服务。例如,我们可以看到 SwissMap 每实例随机化在复制时的成本。
- 直接节省很明确:移除这种随机化会帮助复制密集型用户提升性能。
- 间接成本则没那么明显:失去随机化会让针对 lookup、insertion 等常见操作添加优化变得更难。这会伤害整个生态,很可能也包括复制密集型用户。
结语
深入研究单个工作负载可以产生新的优化洞见。找到把这些洞见扩展到更广生态的方法,可以增加我们的影响力:既移除该工作负载的瓶颈,也系统性地解决问题。