每周性能技巧 #93:机器人从不睡觉
本节阅读量:本文翻译自 Abseil 官网的 Performance Tip of the Week #93: Robots never sleep。
原文发布于 2025 年 5 月 27 日,作者 Chris Kennelly,更新于 2025 年 6 月 3 日。
presubmit 这类技术是有效软件工程的关键工具。对于性能优化来说,改变可观察但未指定的行为,可能带来大量机会。本篇讨论如何依靠自动化和额外工具,让软件更容易演进。
通过技术手段预防问题
使用技术手段,而不是最终会犯错的人,来预防问题。人类可以成为防御未知未知问题的宝贵一线,但不能成为所有事情的唯一防线。人工执行的检查清单很好,但它们也必须短小,因此不适合编码大量领域知识。
每次事故后都想往清单里加东西且永不删除,这很诱人,但最终会导致不可避免的 toil 或跳过步骤。机器人永远不会累。
我们引入 GoodFastHash 作为 std::hash 的更快替代时,本意是可以不时更改算法。这个性质写在注释里。随着时间推移,代码逐渐依赖了它的既有行为:从依赖 std::unordered_map 搭配 GoodFastHash 的稳定迭代顺序的脆弱测试,到依赖它作为稳定缓存 key 的生产设计。这阻碍了 GoodFastHash 的性能和哈希质量改进。
自动化测试是众所周知的软件技术,但也值得把自动化以新方式用于新问题,以便左移。举一个性能之外的例子:许多事故都是由配置中大规模、意外的变化引发的。例如,配置大小减少 100.0% 很可能是 bug,也是即将发生的事故。
空配置文件可以机械检测出来,让人类去担心那些更微妙的陷阱。presubmit 检查如果能检测并阻止没有显式例外的大 diff,就能把许多问题拦在路上。
我们可以把自动化用于长期保持代码灵活,让代码更高效,同时确保可靠性。
驯服微妙性的策略
随着软件栈复杂度增长,可靠且手工照看每个角落非常困难。API 可能承诺一件事,但建立在其上的软件可能依赖栈更深处的实现细节。
编译期加固
最早的 C++ TotW 讲的是 string_view,它可以通过避免字符串复制提升性能。这让它在常规使用中很有吸引力,但由于悬垂引用,也容易出 bug。
像 absl::string_view 和 absl::Span 这样的引用型类型,让我们可以编写不关心底层数据存储方式的代码。字符串可能由全局常量数据支持,也可能由堆上分配的副本支持。N 个元素的容器可能存储为 absl::InlinedVector,在常见情况下移除一次堆间接访问。
和其他引用一样,这些类型也有缺点:引用使用点可能与底层存储生命周期不一致,导致 use-after-free 或 use-after-return bug。虽然 sanitizer 可以检测这些 bug,但它们会受测试覆盖影响而产生漏报。幸运的是,ABSL_ATTRIBUTE_LIFETIME_BOUND 这类源码注解可以在编译期发现其中许多问题。
这些注解让我们把 bug 发现左移,使正在写代码的工程师可以立即修复问题,而不是等到生产事故出现并承担更高成本。
类似地,锁注解可以让我们以程序化方式传达线程安全要求,从而在编译期检查。我们不需要手工记住每次访问成员变量都要持锁,而是把未持锁访问转化为构建失败。
虽然这些注解无法表达微妙但正确的加锁策略,但它们让我们可以把更多时间预算留给那些微妙情况,因为普通并发模式不再需要仔细审计。
运行时加固
随着时间推移,我们发展出几种抵御 Hyrum 定律的技术。虽然有许多实现细节可以被模糊化,但下面这些技术已经证明有用:
- 用内存地址提供低成本熵。取全局变量地址非常高效(x86 上是
rip相对lea),并驱动absl::Hash的随机化。吸取GoodFastHash的经验后,absl::Hash有意使用随机数给哈希计算加 seed。这确保测试更健壮,因为它们无法依赖脆弱实现细节。
因此,我们已经能够落地若干优化,整体替换其实现,而不会被脆弱性阻塞。
这会从 ASLR 和链接顺序中引入刚刚够用的熵,使跨测试和生产任务依赖这些值变得困难。类似地,堆分配也可以提供实例级熵。我们在 SwissMap 中使用这一点,确保不同表实例有不同迭代顺序。
- 在 debug 构建或采样中加入额外检查。我们希望保持实现自由度,以确保未来可以改善性能。不过,如果昂贵检查放在优化构建中,会降低性能,违背部分目的。因此,大部分检查可以放在 debug 构建中。
C++14 的 sized delete 允许释放路径避免昂贵的 radix tree 遍历,但错误大小会导致数据破坏。在 debug 构建中,TCMalloc 会根据内部元数据真值检查传入大小。TCMalloc 已经会周期性采样来驱动 heap profiling,因此也能对这些采样对象提供额外检查,以便在生产中主动发现更多问题。
- 反事实检查。在 sanitizer 构建中,我们检查 SwissMap 迭代器在任何潜在失效点之后是否仍保持有效。即便某次插入没有造成 rehash,但它本可以造成 rehash,那么迭代器也被视为无效。这让我们可以防止代码依赖 SwissMap 的增长率、迭代器失效行为等,即使日常构建中使用的当前实现超出了保证。
广泛启用这些防御意味着我们可以从所有人的测试中受益。这些方法确保代码不依赖敏感于实现细节的特征,因此我们可以快速更改实现而不破坏代码。
静态分析
Clang-Tidy 这类工具可以发现问题模式,并在代码评审时标记它们。这可以左移问题预防,而不是等生产中的运行时检查失败。
例如,某些 protobuf 优化对 const_cast 的误用很敏感。把默认实例放在全局只读存储中,可以部分阻止这种误用,因为程序在修改实例时会崩溃。由于在可能测试不足的代码路径上崩溃并不理想,而且这种技术无法覆盖所有误用类型,因此 Clang-Tidy 检查可以在类型为 protobuf 实例时标记 const_cast。
棘轮和棘爪
Matt Kulukundis 推广了“ratchet-and-pawl”这个说法,用来描述防止倒退的增量迁移。这允许我们在可推进处前进,同时确信问题不会变得更糟。
在 SwissMap 迁移期间,由于测试失败和真实生产代码依赖当前行为,无法随机化所有既有 unordered 容器。测试通过(或被修复到通过)的单个实例可以被迁移。随着迁移推进,对较少使用容器(dense_hash_map)的可见性 allowlist,以及对更常见容器(std::unordered_map)的 Clang-Tidy 检查,减少了新旧容器用法。
一个含有上千条目的 allowlist 看起来可能不优雅,但它是防止倒退的强大工具。随着用法被移除,可以手工或通过自动清理逐步收紧这个列表。
避免保证的成本
保持实现自由度会带来成本,无论是需要绕过它导致的性能开销、机会成本,还是单纯更差的人体工学。
在哈希表上,改变哈希算法、初始大小和增长率曾反复被提出,但在 SwissMap 和 Abseil Hash 引入之前,经常被脆弱测试阻碍。SwissMap 使用堆分配地址作为熵源,这会让原始类型上的复制更昂贵:不能简单 memcpy 原始数据,而需要重新哈希 key。让顺序更确定可能提升复制性能,但整体收益超过了这些小成本。
保持尽可能多的实现灵活性是有用的,但也要聚焦最可能改变的实现细节。仅仅因为我们可以模糊某个实现细节,并不一定意味着如果它不太可能改变或改变价值不大,就值得付出运行时和工程成本。
验证策略选择
对于大型、性能关键优化,我们可能希望仔细测试期望的性能特征是否保持,即使没有该优化时代码在功能上也正确。
- TCMalloc 的测试过去经常依赖大量真实分配来覆盖边界情况。但随着 Temeraire 开发,测试可以通过策略模拟 GB 级内存使用,而不实际使用那么多物理内存。利用这种设计选择,可以测试更多边界情况,并在新改进上线时避免回退。
- Protobuf 会在若干情况下省略复制。测试确保这个实现细节被保留,避免显著回退。
如果我们喜欢自己的优化,就应该给它加测试。自动化能在前置阶段防止回退,而不是等事后调试。
结语
把问题发现从人类转移到自动化机制上,可以让我们专注更大的图景并提升速度。当事情确实坏掉时,长期看也能节省人类时间,因为我们可以少花精力定位回退原因。