原文链接:https://abseil.io/fast/hints.html#performance-hints

高德纳的这句名言确实困扰过很多人,业界常常把“过早优化是万恶之源”当成系统前期可以随意挥霍性能、堆砌抽象的借口。这篇文章最内核的价值,就是把这层窗户纸捅破了:如果一种更快的实现方式并不会增加代码的复杂度和阅读门槛,那它就根本不是“过早优化”,而是工程质量的底线。

剥开长文中那些具体的 C++ 特性(如 std::string_viewabsl::InlinedVector)和 Google 内部库的抽象,Jeff 和 Sanjay 其实在反复向开发者传递几个非常底层的系统第一性原理:

机械同理心与物理常识(Mechanical Sympathy)

性能优化的尽头是硬件的物理实在。 文章中提到的“封底估算(Back-of-the-envelope calculations)”,本质上是要求工程师将 CPU 缓存、内存寻址、分支预测和总线带宽的开销转化为肌肉记忆。长文中大量篇幅讨论的紧凑数据结构、用数组索引代替 64 位指针、批量存储以及使用 Arena 内存池,核心目的只有一个:迎合 CPU 的缓存行(Cache Line)和预取机制。在现代体系结构下,频繁的内存分配和指针的跳跃(Pointer Chasing)是极度昂贵的。让数据在内存中紧凑、连续地排列,让底层硬件顺畅地跑满流水线,往往比纯粹数学意义上的时间复杂度优化更能带来实质性的飞跃。

降低边界跨越的摩擦成本

这集中体现在他们对 API 设计的建议上,比如大力推崇批量接口(Bulk APIs)和视图类型。无论是函数压栈、RPC 跨网络边界,还是互斥锁的获取,跨越系统边界都伴随着巨大的上下文开销。通过批量操作(例如一次加锁处理完所有引用,或者批量查询),可以极大地摊销(Amortize)系统在控制面上的决策成本。系统设计应该遵循极简原则:能一次性批处理完的控制流,就绝对不要让系统去做一万次微小的状态流转。

利用非对称性:为常见场景修筑高速公路

系统运行时的输入和状态从来都不是均匀分布的。文章中强调的“常见情况的快速路径(Fast paths)”就是利用了概率上的非对称性。如果 99% 的数据都是短字符串或单字节,或者绝大多数执行都不会遇到错误,那么就应该为这 99% 的常态修筑一条没有任何额外分配、判断极简的“高速公路”,而把那些繁琐的、需要深层检查的逻辑剥离出去,推迟到冷路径(Cold path)或尾调用中。这同时也是在提醒我们,警惕代码的“过度通用化”。

防御性的性能架构(长期主义思维)

回到最开始关于“过早优化”的讨论。如果我们前期对那些顺手就能写好的细节(比如预留容器容量、避免过度嵌套的 Map、消除无意义的深拷贝)视而不见,系统在经历多轮迭代后,就会陷入一种叫做“扁平性能概况(Flat Profile)”的死局——性能损耗像撒胡椒面一样均匀分布在无数个微小的分配和拷贝中,剖析工具根本找不到一个突出的瓶颈。在不破坏架构简洁性的前提下,保持底线的性能嗅觉,实际上是对系统演进的长期主义负责。这不是在做无用功,而是在防御未来因系统性能全面溃散而不得不进行的推翻重写。

性能优化建议 (Performance Hints)

作者:Jeff Dean, Sanjay Ghemawat
原始版本:2023/07/27,最后更新:2025/12/16

多年来,我们(Jeff 和 Sanjay)在各种代码片段的性能调优上投入了大量精力。自 Google 成立之初,提升软件性能就至关重要,因为这让我们能够为更多用户提供更多服务。我们编写本文档旨在总结我们在进行此类工作时运用的一些通用原则和特定技巧,并尝试挑选具有代表性的源代码变更(Change Lists,简称 CLs)作为具体方法和技巧的示例。尽管下文中的具体建议大多涉及 C++ 类型和 CL,但其通用原则同样适用于其他语言。

本文档聚焦于单个二进制文件上下文中的通用性能调优,不涵盖分布式系统或机器学习(ML)硬件的性能调优(这些本身就是庞大的领域)。希望这份文档能对大家有所帮助。

文档中的许多示例都包含演示相关技巧的代码片段。请注意,部分代码片段可能提及 Google 内部代码库的各种抽象。如果我们认为这些示例足够独立,即便不熟悉这些抽象细节也能理解,我们仍会将其收录其中。

思考性能的重要性

Knuth(高德纳)常被断章取义地引用为“过早优化是万恶之源”。这句话的完整版本是:“在 97% 的情况下,我们要忽略微小的效率提升:过早优化是万恶之源。然而,我们绝不能放弃那关键的 3% 的机会。” 本文档正是关于这关键的 3%。Knuth 还有一段更具说服力的引言:

“从示例 2 到示例 2a 的速度提升仅约 12%,许多人可能会认为这微不足道。当今许多软件工程师的传统观念是忽略细微处的效率;但我认为,这仅仅是对那些实行‘省小钱亏大钱’、无法调试或维护其‘优化后’程序的程序员所犯滥用行为的过度反应。

在成熟的工程学科中,轻易获得 12% 的改进绝不会被视为微不足道;我相信这一观点同样应在软件工程中占据主导地位。当然,我不会在一个一次性的任务上费心做这种优化,但当问题涉及到编写高质量程序时,我不想局限于那些剥夺我获得这种效率的工具。”

许多人会说:“让我们先用尽可能简单的方式编写代码,等以后能进行性能剖析(Profiling)时再处理性能问题。” 然而,这种方法通常是错误的:

  1. 如果在开发大型系统时无视所有性能问题,最终你会得到一个扁平的性能概况(Flat Profile),即没有明显的热点,因为性能损耗遍布各处。这时要弄清楚从何处着手改进性能将变得非常困难。
  2. 如果你正在开发一个供他人使用的库,遇到性能问题的人很可能是那些无法轻易进行性能改进的人(他们不得不去理解其他团队编写的代码细节,并与这些团队协商性能优化的重要性)。
  3. 当系统处于高负载使用状态时,进行重大更改会更加困难。
  4. 很难判断性能问题是否可以轻易解决,因此我们最终可能会采用昂贵的解决方案,如过度复制或为了处理负载问题而严重过度配置服务资源。

相反,我们要建议的是:在编写代码时,如果对代码的可读性或复杂度没有显著影响,请尽量选择更快的替代方案

这段话其实打破了我对 “过早优化是万恶之源” 的教条认识,我一直没有真正理解这句话

估算 (Estimation)

如果你能对自己正在编写的代码的性能重要性建立直觉,你就能做出更明智的决策(例如,以性能为名引入多少额外的复杂度是合理的)。

以下是编写代码时估算性能的一些技巧:

在选择具有潜在不同性能特征的选项时,你可以通过依赖封底计算(Back-of-the-envelope calculations) 来进行稍深层次的分析。此类计算可以快速给出不同方案的非常粗略的性能估算,其结果可用于舍弃某些方案,而无需实际实现它们。

估算可以这样进行:

  1. 估算各种类型的底层操作所需的数量,例如磁盘寻道次数、网络往返次数、传输字节数等。
  2. 将每种昂贵操作的数量乘以其粗略成本,并将结果相加。
  3. 上述计算给出了系统在资源使用方面的成本。如果你对延迟感兴趣,且系统存在并发,部分成本可能会重叠,你需要进行稍微复杂的分析来估算延迟。

下表列出了一些底层操作及其粗略成本,这可能很有用(该表是 2007 年斯坦福大学演讲表格的更新版本):

操作类型 (Operation) 粗略耗时 (Rough Cost)
L1 缓存引用 (L1 cache reference) 0.5 ns
L2 缓存引用 (L2 cache reference) 3 ns
分支预测错误 (Branch mispredict) 5 ns
互斥锁 加锁/解锁 (无竞争) 15 ns
主内存引用 (Main memory reference) 50 ns
使用 Snappy 压缩 1K 字节 1,000 ns
从 SSD 读取 4KB 20,000 ns
同一数据中心内的网络往返 50,000 ns
从内存顺序读取 1MB 64,000 ns
在 100 Gbps 网络上读取 1MB 100,000 ns
从 SSD 读取 1MB 1,000,000 ns
磁盘寻道 (Disk seek) 5,000,000 ns
从磁盘顺序读取 1MB 10,000,000 ns
发送数据包 CA->Netherlands->CA 150,000,000 ns

上表包含了基本底层操作的粗略成本。你可能会发现,追踪与你的系统相关的高层操作的估算成本也很有用。例如,你可能想知道 SQL 数据库点查(Point read)的粗略成本、与云服务交互的延迟,或渲染一个简单 HTML 页面所需的时间。如果你不知道不同操作的相关成本,你就无法进行像样的封底计算!

示例:对十亿个 4 字节数字进行快速排序的时间

作为粗略近似,一个优秀的快速排序算法需要对大小为 $N$ 的数组进行 $\log(N)$ 次遍历。在每次遍历中,数组内容将从内存流式传输到处理器缓存,分区代码将每个元素与枢轴元素(Pivot)进行一次比较。让我们把主要成本加起来:

  1. 内存带宽: 数组占用 4GB(4 字节/数字 $\times$ 10 亿个数字)。假设每核心内存带宽约为 16GB/s。这意味着每次遍历大约需要 0.25 秒。$N$ 约为 $2^{30}$,所以我们需要进行约 30 次遍历,因此内存传输的总成本约为 7.5 秒

  2. 分支预测错误: 我们将总共进行 $N \times \log(N)$ 次比较,即约 300 亿次比较。假设其中一半(即 150 亿次)预测错误。乘以每次预测错误 5 ns,我们得到预测错误的成本为 75 秒。在此分析中,我们假设正确预测的分支是免费的。

将上述数字相加,我们得到的估算值约为 82.5 秒

如果有必要,我们可以改进分析以考虑处理器缓存。这种改进可能是不必要的,因为根据上述分析,分支预测错误是主要成本,但作为另一个例子,我们仍在此将其包括在内。假设我们有 32MB 的 L3 缓存,并且从 L3 缓存传输数据到处理器的成本可以忽略不计。L3 缓存可以容纳 个数字,因此最后 22 次遍历可以对驻留在 L3 缓存中的数据进行操作(倒数第 23 次遍历将数据带入 L3 缓存,其余遍历在这些数据上操作)。这将内存传输成本从 7.5 秒(30 次内存传输)减少到 2.5 秒(10 次 4GB 内存传输,16GB/s)。

示例:生成包含 30 个图像缩略图的网页的时间

假设原始图像存储在磁盘上,每个图像大小约为 1MB。让我们比较两种潜在的设计:

  1. 串行设计: 串行读取 30 个图像的内容并为每个图像生成缩略图。每次读取需要一次寻道 + 一次传输,即寻道 5ms,传输 10ms,每个图像合计 15ms。30 个图像总共需要 450ms
  2. 并行设计: 假设图像均匀分布在 个磁盘上,进行并行读取。之前的资源使用估算仍然成立,但延迟将大致下降 倍(忽略方差,例如有时候我们会运气不好,一个磁盘上的图像超过 )。因此,如果我们在拥有数百个磁盘的分布式文件系统上运行,预期延迟将降至约 15ms

让我们考虑一种变体,其中所有图像都在单个 SSD 上。这将顺序读取性能变为 /图像,总共约 30 ms

测量 (Measurement)

上一节提供了一些关于在编写代码时如何思考性能的技巧,而不必太担心如何测量选择的性能影响。但是,在你实际开始进行改进,或遇到涉及性能、简洁性等权衡时,你会想要测量或估算潜在的性能收益。能够有效地测量事物是你进行性能相关工作时最想拥有的首要工具。

顺便提一下,对你不熟悉的代码进行性能剖析(Profiling)也是获得代码库结构及其运行方式一般感知的绝佳方法。检查程序动态调用图中重度参与例程的源代码,可以让你对运行代码时“发生了什么”有一个高层次的认识,这可以建立你在稍显陌生的代码中进行性能改进更改的信心。

剖析工具和技巧

有许多有用的剖析工具可用。首选工具是 pprof,因为它提供了良好的高层性能信息,并且易于在本地和生产环境中运行的代码上使用。如果你需要更详细的性能洞察,也可以尝试 perf

这里提到的 pprof 是什么?

一些剖析技巧:

先不要盲目猜测代码哪里慢,先运行这个低开销的工具,抓取一个样本,通过图表直观地看到 CPU 时间或内存主要消耗在哪里,然后再进行针对性的优化。

当剖析结果扁平(Flat)时该怎么办

你经常会遇到 CPU 剖析结果是扁平的情况(没有明显的导致缓慢的罪魁祸首)。这通常发生在所有容易摘的果实(Low-hanging fruit)都已被摘完之后。如果你发现自己处于这种情况,请考虑以下建议:

API 设计考量 (API Considerations)

下面建议的一些技术需要更改数据结构和函数签名,这可能会对调用者造成破坏。尽量组织代码,使得建议的性能改进可以在封装边界内进行,而不影响公共接口。如果你的模块较深(Deep Modules)(通过窄接口访问显著的功能),这将更容易实现。

广泛使用的 API 面临着添加功能的巨大压力。在添加新功能时要小心,因为这些将限制未来的实现,并为不需要新功能的用户增加不必要的成本。例如,许多 C++ 标准库容器承诺迭代器的稳定性(Iterator stability),这在典型的实现中显著增加了分配次数,即使许多用户并不需要指针稳定性。

下面列出了一些具体技巧。请仔细权衡性能优势与这些更改引入的任何 API 可用性问题。

批量 API (Bulk APIs)

提供批量操作以减少昂贵的 API 边界跨越或利用算法改进。

有时很难更改调用者以直接使用新的批量 API。在这种情况下,在内部使用批量 API 并缓存结果以供将来的非批量 API 调用使用可能是有益的:

视图类型 (View types)

对于函数参数,优先使用视图类型(例如 std::string_view, std::Span<T>, absl::FunctionRef<R(Args…)>),除非数据的所有权正在转移。这些类型减少了复制,并允许调用者选择自己的容器类型(例如,一个调用者可能使用 std::vector 而另一个使用 absl::InlinedVector)。

预分配/预计算参数

对于频繁调用的例程,有时允许上层调用者传入它们拥有的数据结构或被调用例程需要的、客户端已有的信息是有用的。这可以避免底层例程被迫分配自己的临时数据结构或重新计算已经可用的信息。

线程兼容 (Thread-compatible) vs 线程安全 (Thread-safe) 类型

类型可以是线程兼容的(外部同步)或线程安全的(内部同步)。大多数通用类型应该是线程兼容的。这样,不需要线程安全的调用者就不必为此付费。

然而,如果类型的典型用法需要同步,则倾向于将同步移至类型内部。这允许在必要时调整同步机制以提高性能(例如,分片以减少竞争),而不影响调用者。

算法改进 (Algorithmic Improvements)

性能改进的最关键机会来自算法改进,例如将 $O(N^2)$ 算法变为 $O(N \lg N)$ 或 $O(N)$,避免潜在的指数行为等。

这些机会在稳定代码中很少见,但在编写新代码时值得关注。

更好的内存表示 (Better memory representation)

仔细考虑重要数据结构的内存占用和缓存占用通常可以产生巨大的节省。以下数据结构专注于通过触摸更少的缓存行来支持常见操作。这里的关注点可以 (a) 避免昂贵的缓存未命中 (b) 减少内存总线流量,这不仅可以加速相关程序,还可以加速同一台机器上运行的其他程序。

紧凑数据结构 (Compact data structures)

对访问频繁或占应用内存很大一部分的数据使用紧凑表示。

索引代替指针

在现代 64 位机器上,指针占用 64 位。如果是指针密集型数据结构,你可以考虑使用数组 T[] 的整数索引。如果索引数量足够小(32 位或更少),引用不仅更小,而且所有 T[] 元素的存储都是连续的,通常会导致更好的缓存局部性(Cache Locality)

批处理存储 (Batched storage)

避免为每个存储元素分配单独对象的数据结构(如 C++ 中的 std::map, std::unordered_map)。相反,考虑使用分块或扁平表示将多个元素在内存中紧邻存储的类型(如 std::vector, absl::flat_hash_{map,set})。

内联存储 (Inlined storage)

一些容器类型针对存储少量元素进行了优化(如 absl::InlinedVector)。它们避免了元素数量较少时的堆分配。

不必要的嵌套映射

有时可以用带有复合键的单层映射替换嵌套映射数据结构。这可以显著降低查找和插入的成本。

区域内存 (Arenas)

Arena 可以帮助减少内存分配成本,并且具有将独立分配的项目紧挨着打包在一起的好处(通常在更少的缓存行中),并消除大多数析构成本。对于具有许多子对象的复杂数据结构最有效。

数组代替映射 / 位向量代替集合

减少分配 (Reduce allocations)

内存分配增加成本:

  1. 增加在分配器中花费的时间。
  2. 新分配的对象可能需要昂贵的初始化,以及不再需要时相应的昂贵销毁。
  3. 每次分配往往位于新的缓存行上,因此分散在许多独立分配上的数据将比分散在较少分配上的数据具有更大的缓存占用。

避免不必要的分配

调整容器大小或预留 (Resize or reserve containers)

当已知向量(或其他容器)的最大或预期最大大小时,预先调整容器后备存储的大小(例如,使用 reserve)。

尽可能避免复制

重用临时对象

将在循环内声明的容器或对象提升到循环外,以实现跨迭代重用,避免重复的构造/析构/调整大小。

避免不必要的工作 (Avoid unnecessary work)

常见情况的快速路径 (Fast paths for common cases)

代码通常编写为覆盖所有情况,但某些子集比其他情况更简单且更常见。

预先计算昂贵的信息 (Precompute expensive information)

将昂贵的计算移出循环

延迟昂贵的计算 (Defer expensive computation)

特化代码 (Specialize code)

特定的性能敏感调用点可能不需要通用库提供的全部通用性。

使用缓存避免重复工作

让编译器的工作更轻松

减少统计收集成本

平衡统计信息的效用与维护信息的成本。

避免在热点代码路径上记录日志

即使是未启用的日志语句也可能有成本(加载和比较)。

代码体积考量 (Code size considerations)

大的代码体积意味着更长的编译和链接时间、臃肿的二进制文件、更多的内存使用、更大的指令缓存(icache)压力。

并行化和同步 (Parallelization and synchronization)

利用并行性

现代机器有许多核心。通过并行化可以更快地完成昂贵的工作。

摊销锁获取 (Amortize lock acquisition)

保持临界区简短

避免在临界区内进行昂贵的工作(RPC、文件访问)。

通过分片减少竞争 (Reduce contention by sharding)

减少伪共享 (Reduce false sharing)

如果不同的线程访问不同的可变数据,请考虑将不同的数据项放在不同的缓存行上(例如,使用 alignas)。

考虑无锁方法 (Consider lock-free approaches)

Protocol Buffer 建议

Protobuf 很方便,但有性能成本。将数据从 Protobuf 转换为 C++ std::vector 可能会有 20 倍的加速。

C++ 特定建议

批量操作 (Bulk operations)

扩展阅读