原文链接:https://abseil.io/fast/hints.html#performance-hints
姊妹篇:[[「译」Performance Hints]](C++ 原味版)。

本篇是基于逐字原文(abseil.github.io 源码 fast/hints.md,2025/12/16 版)的完整忠实翻译:保留原文全部章节、子节与每一条 CL 示例(含其关键基准数字),并把其中的 C++ 类型与代码改写成 Rust 等价物。所有 Rust 映射均为译者添加、以引注或 "Rust 备注 " 标出;技术判断与数字一律以原文为准。

关于这一版

原文的通用原则(估算、测量、算法改进、缓存友好、批量摊销、快速路径)与语言无关,真正 "C++ 味 " 的是那些具体类型(std::string_viewabsl::InlinedVectorabsl::flat_hash_mapStatusOr 等)与 Google 内部库抽象。本篇保留原文的章节结构、每条 CL 示例及其数字,只把这些具体载体换成 Rust 世界里的对应物,并在有意思处点出 Rust 与 C++ 的差异——很多在 C++ 里需要 " 刻意为之 " 的优化,在 Rust 里恰是默认行为(移动语义、&mut 独占借用),这本身就是理解 " 机械同理心 " 的好切口。

Rust 代码片段追求 " 读得懂 + 说清动因 ",不保证可直接 cargo build;涉及的第三方 crate 会标出名字,方便去 docs.rs 查。

C++ → Rust 概念映射总表

原文 C++ 载体 Rust 等价物 说明 / 动因
std::string_view &str 借用而不拥有,零拷贝视图
absl::Span<T> / std::span<T> &[T] / &mut [T](切片) 语言内建,调用者自选底层容器
absl::FunctionRef<R(Args…)> &dyn Fn(Args) -> Rimpl Fn(…) 不拥有闭包,避免装箱分配
std::vector<T> Vec<T> 堆上连续存储
absl::InlinedVector<T, N> smallvec::SmallVec<[T; N]> / arrayvec::ArrayVec<T, N> 小容量内联栈上,省堆分配
absl::FixedArray<T> Box<[T]> / Vec<T>with_capacity 后不增长) 定长堆数组
gtl::vector32<T> Rust 无直接对应;用 u32 索引下标即可 用 32 位而非 64 位的 size/capacity 省内存
absl::flat_hash_map/set(Swiss Tables) std::collections::HashMap/HashSet std 自 1.36 起底层即 hashbrown(Swiss Tables 的 Rust 移植),SIMD 探测已内建
absl::btree_map/set std::collections::BTreeMap/BTreeSet 缓存友好的有序容器
std::map / std::unordered_map BTreeMap / HashMap
gtl::small_map 元素极少时用 Vec<(K,V)> 线性扫描 小数据集免哈希开销
gtl::small_ordered_set 元素极少时用有序 Vec<T> + 二分 小有序集合
gtl::intrusive_list 侵入式链表:intrusive-collections crate,或 Vec+ 索引 省每元素一次分配与一条缓存行的间接
std::vector<bool> / util::bitmap::InlinedBitVector bitvecfixedbitsetbit-set crate 位打包,集合运算变位运算
Arena(google::protobuf::Arena 等) bumpalo::Bump(不逐个析构)、typed-arena(会析构内容)、id-arena/slotmap 批量分配、(视 crate)批量释放;注意各 crate 的 Drop 语义不同
用索引代替指针 Vec<T> + u32 索引 / id-arena / slotmap Rust 里构图惯用法,同时绕开借用检查器的自引用难题
absl::Status / StatusOr<T> Result<(), E> / Result<T, E>;非错误的 " 缺失 " 用 Option<T> 热路径避免构造重错误对象;让 E 廉价
move vs copy Rust 默认移动;Copy 标记廉价按位复制;.clone() 显式深拷贝 " 优先 move" 在 Rust 里是编译器默认,拷贝必须显式写出
reserve / resize Vec::with_capacity / Vec::reserve
std::sort vs std::stable_sort sort_unstable(pdqsort)vs sort(稳定,需辅助内存) 不需要稳定性就用 unstable
StrCat / StringPrintf / sprintf format! / write! / String::push_str 热路径避免 format!,直接拼 buffer
alignas(64) / ABSL_CACHELINE_SIZE 防伪共享 #[repr(align(64))]crossbeam_utils::CachePadded<T> 把互相独立的可变字段隔到不同缓存行
lock-free map / concurrent hash map dashmap(内部分片);只读多写少整体替换用 arc-swap 分片/无锁并发
线程兼容 vs 线程安全 Send/Sync + 外部 Mutex/RwLock vs 内部同步 借用检查器让 " 线程兼容 " 成为零成本默认
protobuf prost / protobuf crate Rust 里 arena 关联弱
protobuf Cord[ctype=CORD] bytes::Bytes(引用计数共享) 减少大字段复制
protobuf string_type = VIEW &[u8] / &str(借用后备缓冲,生命周期系于原缓冲) 非拥有借用,与 Cord 不同
pprof cargo flamegraphsamplypprof-rsperf Rust 生态采样剖析工具
Lazy init std::sync::LazyLock/OnceLock(1.80+)、once_cell 首次使用才初始化
手动展开 / 批量字节 手写展开仍可;或 chunks_exact(N) + bytemuck;SIMD 用 std::simd(nightly)/std::arch 原文强调 " 手动展开非常热的循环 "
SIMD 指令 std::arch(intrinsics)、std::simd(nightly)、wide/bytemuck crate 一次处理多个元素
缓冲通道做流水线 std::sync::mpsc::sync_channel(n)crossbeam-channel 有界通道 有缓冲才能增并行;无缓冲仅作同步
#[inline] 控制 #[inline] / #[inline(always)] / #[inline(never)] / #[cold] 慢路径标 #[cold] 拆出内联
泛型单态化膨胀 把大段与类型无关的逻辑抽到非泛型内核函数,泛型外壳只转发 对应原文 " 减少模板实例化 "

性能优化建议 (Performance Hints) · Rust 版

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

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

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

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

思考性能的重要性 (The importance of thinking about performance)

Knuth 有一句常被断章取义引用的话:过早优化是万恶之源 (premature optimization is the root of all evil)完整原文 是:" 在大约 97% 的时间里,我们应当忘掉那些小的效率提升:过早优化是万恶之源。然而,我们不应错过那关键的 3% 中的机会。" 本文讨论的正是那关键的 3%。Knuth 还有另一段更有说服力的话:

从示例 2 到示例 2a 的速度提升只有大约 12%,许多人会认为这微不足道。当今许多软件工程师所共有的传统观念主张忽略微观层面的效率;但我认为这只是对他们所见到的、那些因小失大的程序员滥用优化的做法的一种过度反应——这些程序员根本无法调试或维护他们 " 优化过 " 的程序。在成熟的工程学科中,一个轻易可得的 12% 的提升绝不会被视为无关紧要;我相信同样的观点也应在软件工程中占主导地位。当然,对于一次性的工作我不会费心去做这种优化,但当涉及到编写高质量的程序时,我不想把自己局限在那些不允许我获得此类效率的工具上。

许多人会说 " 让我们用尽可能简单的方式把代码写下来,等到能够做剖析时再处理性能问题 "。然而,这种做法往往是错误的:

  1. 如果你在开发一个大型系统时无视所有性能问题,最终你会得到一份平坦的剖析结果(flat profile),其中没有明显的热点,因为性能是在各个地方一点一点流失掉的。届时将很难弄清楚该从何处着手进行性能改进。
  2. 如果你正在开发一个将被其他人使用的库,那么最终遭遇性能问题的人,很可能是那些难以轻易做出性能改进的人(他们必须理解由其他人/团队编写的代码细节,还得就性能优化的重要性与对方进行协商)。
  3. 当一个系统被大量使用时,对它做出重大改动会更加困难。
  4. 同时也很难判断是否存在一些可以轻松解决的性能问题,于是我们最终可能采用一些代价高昂的方案,例如过度复制(over-replication)或严重超额配置(severe overprovisioning)某个服务,以应对负载问题。

因此,我们建议:在编写代码时,如果更快的方案不会显著影响代码的可读性/复杂度,就尽量选择更快的那一个。

估算 (Estimation)

如果你能培养出一种直觉,去判断你正在编写的代码中性能可能有多重要,你就能做出更明智的决策(例如,为了性能到底值得引入多少额外的复杂度)。以下是在编写代码时估算性能的一些技巧:

当你在两个可能具有不同性能特征的选项之间做选择时,可以依靠 信封背面估算 (back of the envelope calculations) 来做稍深入一些的分析。这类估算可以快速地给出不同方案性能的一个非常粗略的估计,其结果可用于在无需实现的情况下就淘汰掉一部分方案。

这样的估算大致可以这样进行:

  1. 估算需要多少各种类型的底层操作,例如:磁盘寻道次数、网络往返次数、传输的字节数等。
  2. 将每一种昂贵操作乘以其大致成本,然后把结果相加。
  3. 上述过程给出的是系统以资源使用衡量的成本 (cost)。如果你关心的是延迟,并且系统具有一定的并发性,那么其中一些成本可能会相互重叠,你可能需要做稍微复杂一点的分析来估算延迟。

下面这张表是 2007 年斯坦福大学一次演讲 中一张表格的更新版本(2007 年演讲的视频已不复存在,但有一个 涵盖部分相同内容的、相关的 2011 年斯坦福演讲视频)。它可能很有用,因为它列出了需要考虑的操作类型及其大致成本:

L1 缓存引用 (L1 cache reference)                        0.5 ns
L2 缓存引用 (L2 cache reference)                          3 ns
分支预测错误 (Branch mispredict)                          5 ns
互斥锁加锁/解锁(无竞争)(Mutex lock/unlock, uncontended) 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
发送数据包 加州->荷兰->加州                       150,000,000 ns

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

示例:对十亿个 4 字节数字进行快速排序所需的时间 (Example: Time to quicksort a billion 4 byte numbers)

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

  1. 内存带宽:该数组占用 4 GB(每个数字 4 字节乘以十亿个数字)。假设每个核心的内存带宽约为 16GB/s。这意味着每一趟大约需要 0.25s。N 约为 2^30,所以我们将进行约 30 趟,因此内存传输的总成本约为 7.5 秒。
  2. 分支预测错误:我们总共会进行 N*log(N) 次比较,即约 300 亿次比较。假设其中一半(即 150 亿次)被错误预测。乘以每次预测错误 5 ns,得到预测错误成本为 75 秒。在此分析中,我们假设正确预测的分支是免费的。
  3. 把前面的数字加起来,得到约 82.5 秒的估计值。

如有必要,我们可以进一步细化分析以考虑处理器缓存的影响。根据上面的分析,分支预测错误是主导成本,因此这一细化大概并不需要,但我们仍将其作为又一个示例放在这里。假设我们有一个 32MB 的 L3 缓存,并且从 L3 缓存传输数据到处理器的成本可忽略不计。L3 缓存可以容纳 2^23 个数字,因此最后 22 趟可以在驻留于 L3 缓存中的数据上进行操作(倒数第 23 趟把数据带入 L3 缓存,其余各趟在这些数据上操作)。这将内存传输成本从 7.5 秒(30 次内存传输)削减到 2.5 秒(以 16GB/s 传输 4GB,共 10 次内存传输)。

示例:生成一个包含 30 张图片缩略图的网页所需的时间 (Example: Time to generate a web page with 30 image thumbnails)

让我们比较两种可能的设计,其中原始图片存储在磁盘上,每张图片大小约为 1MB。

  1. 串行读取这 30 张图片的内容,并为每一张生成一个缩略图。每次读取需要一次寻道 + 一次传输,寻道 5ms,传输 10ms,合计为 30 张图片乘以每张图片 15ms,即 450ms。
  2. 并行读取,假设图片均匀分布在 K 个磁盘上。前面的资源使用估算仍然成立,但延迟将大致下降 K 倍(忽略方差;例如,我们有时会运气不好,某个磁盘上会有超过 1/K 的待读图片)。因此,如果我们运行在一个拥有数百个磁盘的分布式文件系统上,预期延迟将下降到约 15ms。
  3. 让我们考虑一个变体,即所有图片都在单块 SSD 上。这将顺序读取的性能改变为每张图片 20µs + 1ms,总计约 30 ms。

测量 (Measurement)

前面一节给出了一些关于如何在编写代码时思考性能的技巧,而无需过多担心如何测量你的选择所带来的性能影响。然而,在你真正开始做改进之前,或者当你遇到涉及性能、简单性等各种因素之间的权衡时,你会想要测量或估算潜在的性能收益。能够有效地测量事物,是你在从事性能相关工作时首要想要拥有的工具。

顺带一提,值得指出的是,剖析你不熟悉的代码也可以是获得代码库整体结构及其运作方式的良好途径。审视程序动态调用图中那些高度参与的例程的源代码,能让你对运行代码时 " 发生了什么 " 有一个高层次的认识,进而建立起你在略微陌生的代码中进行性能改进改动的信心。

剖析工具和技巧 (Profiling tools and tips)

有许多有用的剖析工具可供使用。一个值得首先尝试的有用工具是 pprof,因为它能给出良好的高层次性能信息,并且无论是在本地还是对运行于生产环境中的代码都易于使用。如果你想获得更详细的性能洞察,也可以尝试 perf

剖析的一些技巧:

Rust 备注(译者补充):上文提到的 pprof/perf/microbenchmark 工具在 Rust 生态中有对应选择——

  • 火焰图与 CPU 剖析:cargo flamegraphsamply;采集 pprof 格式数据可用 pprof-rspprof crate)。
  • 系统级细粒度剖析:仍可直接使用 perf(Linux),配合上述工具生成火焰图。
  • 微基准测试:使用 criterion(对应 C++/Go/Java 的基准库),它内置统计分析并能防止性能回退。
  • 构建生产二进制并保留调试信息:使用 --release 并在 Cargo.toml 的 profile 中设置 debug = true(例如 [profile.release] debug = true),以获得带符号的优化二进制。

当剖析结果平坦时该怎么办 (What to do when profiles are flat)

你经常会遇到 CPU 剖析结果平坦的情况(没有明显的、造成缓慢的大贡献者)。这种情况通常发生在所有 " 低垂的果实 " 都已被摘取之后。如果你发现自己身处此种境地,以下是一些可供考虑的技巧:

API 设计考量 (API Considerations)

下面建议的一些技术需要改动数据结构和函数签名,这可能会对调用方造成干扰。应尽量组织代码,使这些性能改进能够在封装边界内部完成,而不影响公共接口。如果你的 模块足够"深"(即通过窄接口访问丰富功能),这会更容易做到。

被广泛使用的 API 常常面临添加新特性的巨大压力。添加新特性时要谨慎,因为它们会约束未来的实现,并给那些不需要该特性的用户带来不必要的成本。例如,许多 C++ 标准库容器承诺迭代器稳定性,这在典型实现中会显著增加分配次数,即便许多用户并不需要指针稳定性。

下面列出一些具体技术。请仔细权衡性能收益与此类改动引入的 API 易用性问题。

批量 API (Bulk APIs)

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

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

视图类型 (View Types)

对函数参数优先使用视图类型(如 std::string_viewstd::Span<T>absl::FunctionRef<R(Args…)>),除非要转移数据所有权。这些类型减少拷贝,并允许调用方自选容器类型(例如一个调用方用 std::vector,另一个用 absl::InlinedVector)。

Rust 备注:Rust 中对应的视图类型:

fn process(name: &str, data: &[u8], cb: impl Fn(u32) -> u32) { /* ... */ }

预分配 / 预计算参数 (Pre-allocated/pre-computed arguments)

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

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

一个类型可以是线程兼容的(由外部同步)或线程安全的(内部自行同步)。大多数通用类型应当是线程兼容的。这样,不需要线程安全的调用方就不必为其付出代价。

然而,如果一个类型的典型用法都需要同步,则更倾向于把同步移到类型内部。这样便可按需调整同步机制以提升性能(例如分片以降低争用),而不影响调用方。

算法改进 (Algorithmic improvements)

最关键的性能改进机会来自算法层面的改进,例如把 O(N²) 算法变为 O(N lg(N)) 或 O(N)、避免潜在的指数级行为等。这类机会在稳定代码中很少见,但在编写新代码时值得关注。下面是几个对既有代码进行此类改进的例子:

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

仔细考量重要数据结构的内存占用与缓存占用,往往能带来巨大的收益。下面的数据结构着眼于以触碰更少缓存行的方式支持常见操作。在此下功夫可以:(a) 避免昂贵的缓存未命中;(b) 减少内存总线流量,从而既加速当前程序,也加速同一台机器上运行的其他一切。它们依赖一些通用技巧,你在设计自己的数据结构时可能会发现它们很有用。

紧凑数据结构 (Compact data structures)

对于会被频繁访问、或占应用内存使用很大比例的数据,采用紧凑表示。紧凑表示能显著减少内存使用,并通过触碰更少缓存行、降低内存总线带宽占用来提升性能。但要小心缓存行争用(cache-line contention)。

内存布局 (Memory layout)

对于具有较大内存或缓存占用的类型,仔细考量其内存布局。

Rust 对应:Rust 结构体默认会重排字段以最小化填充;若需固定 C 布局用 #[repr(C)]。用更小的整型(u8/u16/u32)替代默认 i32/i64,枚举可用 #[repr(u8)] 控制判别式宽度。位打包可用 bitflagsbitfield 等 crate,同样需用 criterion 基准验证。

索引代替指针 (Indices instead of pointers)

在现代 64 位机器上,指针占 64 位。如果数据结构富含指针,T* 间接引用很容易吃掉大量内存。改用整数索引指向数组 T[] 或其他数据结构:不仅引用更小(若索引数量小到能放进 32 位或更少),而且所有 T[] 元素的存储是连续的,通常带来更好的缓存局部性。

Rust 对应:用 Vec<T> + u32 索引替代 Box<T>/&T。对图等结构,id-arenaslotmap 是常用方案;这种做法还能绕开借用检查器(borrow checker)对图结构的限制,因为索引不是引用。

批量存储 (Batched storage)

避免为每个存储元素单独分配一个对象的数据结构(如 C++ 的 std::mapstd::unordered_map)。改用采用分块或扁平表示、把多个元素在内存中紧邻存放的类型(如 C++ 的 std::vectorabsl::flat_hash_{map,set})。这类类型往往有好得多的缓存行为,并且分配器开销更小。

一个有用的技巧是把元素划分成块,每块可容纳固定数量的元素。此技巧能显著减少数据结构的缓存占用,同时保持良好的渐近行为。

对某些数据结构,单个块就足以容纳所有元素(如字符串和向量)。其他类型(如 absl::flat_hash_map)也使用这一技巧。

Rust 对应:标准库的 HashMap(基于 hashbrown)本身就是扁平/开放寻址实现,元素存放于连续存储中,无需每元素单独分配;直接使用 VecHashMap 即可获得良好的缓存行为。

内联存储 (Inlined storage)

一些容器类型针对存储少量元素做了优化。它们在顶层为少量元素预留空间,元素数量少时完全避免分配。当这类类型的实例被频繁构造(如作为频繁执行代码中的栈变量)、或同时有很多实例存活时,这非常有帮助。如果一个容器通常只含少量元素,考虑使用内联存储类型之一,如 InlinedVector。

注意:如果 sizeof(T) 很大,内联存储容器可能不是最佳选择,因为内联后备存储会很大。

Rust 对应:用 smallvec::SmallVecarrayvec::ArrayVec,在元素少时把数据存在栈上避免堆分配。

不必要的嵌套映射 (Unnecessarily nested maps)

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

注意:如果第一层映射键很大,保留嵌套映射可能更好:

Rust 对应:用复合键单层 HashMap<(A, B), C> 替代 HashMap<A, HashMap<B, C>>

竞技场分配 (Arenas)

竞技场(arena)能帮助降低内存分配成本,但它们还有额外的好处:把独立分配的项目彼此紧邻打包,通常落在更少的缓存行内,并消除大部分析构成本。它们对含有许多子对象的复杂数据结构最为有效。考虑为竞技场提供一个合适的初始大小(initial size),这有助于减少分配。

注意:竞技场很容易被误用——把太多短生命周期对象放进一个长生命周期的竞技场,会不必要地膨胀内存占用。

Rust 对应bumpalo::Bump 不会对其中内容运行 Drop(这正是 " 消除析构成本 " 的来源),因此不要把持有需要清理资源的 Drop 类型放进去;而 typed-arena::Arena<T> 在竞技场本身被 drop 时,会对其中内容运行 Drop。根据是否需要析构语义选择合适的 crate。

数组代替映射 (Arrays instead of maps)

如果映射的定义域可用一个小整数表示、或是一个枚举、或者映射元素极少,那么该映射有时可以用某种数组或向量替代。

Rust 对应:用 [T; N] 并以枚举(as usize)作为下标索引。

位向量代替集合 (Bit vectors instead of sets)

如果集合的定义域可用一个小整数表示,该集合可以用位向量替代(InlinedBitVector 通常是不错的选择)。在这些表示上,集合运算也可以用按位布尔运算很高效地完成(OR 求并集,AND 求交集,等等)。

Rust 对应:用 fixedbitset::FixedBitSetbitvec 表示位向量/位矩阵。

减少分配 (Reduce allocations)

内存分配带来额外成本:

  1. 增加花在分配器中的时间。
  2. 新分配的对象可能需要昂贵的初始化,并在不再需要时有时需要对应的昂贵析构。
  3. 每次分配往往落在新的缓存行上,因此散布在许多独立分配上的数据,其缓存占用会大于散布在较少分配上的数据。

垃圾回收运行时有时会通过把连续分配顺序放置在内存中来消除第 3 个问题。

避免不必要的分配 (Avoid unnecessary allocations)

此外,当对象生命周期由作用域限定时,优先栈分配而非堆分配(但要小心大对象的栈帧大小)。

Rust 对应:用 static/OnceLock/lazy_static 缓存复用的默认对象;优先栈上值而非 Box/Vec 堆分配。

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

当一个向量(或某些其他容器类型)的最大或预期最大大小事先已知时,预先设置容器后备存储的大小(如 C++ 中用 resizereserve)。

注意(两条):

  1. 不要用 resize/reserve 一次只增长一个元素,那可能导致二次方(quadratic)行为。
  2. 如果元素构造很昂贵,优先做一次初始 reserve 再跟若干 push_back/emplace_back,而不是初始 resize——因为 resize 会让构造函数调用次数翻倍。

Rust 对应:用 Vec::with_capacity / reserve;同理不要在循环里逐个 reserve(1)resize 会为新元素调用构造/克隆,若元素构造昂贵,优先 with_capacity + push

尽可能避免拷贝 (Avoid copying when possible)

Rust 对应:Rust 默认按移动语义传值,所以 " 优先移动 " ≈ " 避免不必要的 .clone()"。排序时优先 sort_unstable(不稳定,无内部额外分配)而非 sort(稳定),除非确实需要稳定性。

复用临时对象 (Reuse temporary objects)

在循环内部声明的容器或对象,会在每次循环迭代时被重新创建,这会导致昂贵的构造、析构和扩容。把声明提升(hoist)到循环外面即可复用,能带来显著的性能提升。(由于语言语义或无法保证程序等价,编译器往往无法自行做这种提升。)

注意:protobuf、string、vector、容器等往往会增长到曾经存入的最大值的大小。因此定期重建它们(例如每使用 N 次之后重建)有助于减少内存需求和重新初始化成本。

Rust 对应:把 String/Vec 等缓冲提升到循环外,每次迭代用 .clear()(保留容量)后复用;但由于容量会保持在历史最大值,可在使用 N 次后重新构造以回收内存。

避免不必要的工作

或许提升性能最有效的一类手段,就是避免那些本就不必做的工作。它有很多种形式,包括:为常见情形创建专门的代码路径以绕开更通用、更昂贵的计算;预计算;把工作推迟到真正需要时再做;把工作提升到执行频率更低的代码片段中;以及其他类似的做法。下面给出这一通用思路的大量示例,并归入几个有代表性的类别。

为常见情形提供快速路径

代码常常被写成覆盖所有情形,但其中某些子集比其他情形要简单得多、也常见得多。例如 vector::push_back 通常有足够空间容纳新元素,但也包含在空间不足时对底层存储进行扩容的代码。对代码结构稍加注意,就能让常见的简单情形更快,同时又不会显著损害不常见情形的性能。

Rust 备注:快速路径 + 慢路径的惯用写法是把慢路径拆成单独函数并标注 #[cold]#[inline(never)],让编译器把它移出内联体:

#[inline]
fn varint_parse(p: &[u8], out: &mut u32) -> usize {
    let b0 = p[0];
    if b0 & 0x80 == 0 { *out = b0 as u32; return 1; }
    varint_parse_slow(p, b0 as u32, out)
}
#[cold]
#[inline(never)]
fn varint_parse_slow(p: &[u8], res: u32, out: &mut u32) -> usize { /* ... */ }

首字节过滤表可用 const FILTER: [bool; 256] = { /* … */ }; 在编译期构造。全 ASCII 快扫可用 slice::iter().position(|&b| b & 0x80 != 0) 或按块处理。

一次性预计算昂贵信息

一般性建议:应在模块边界处检查非法输入,而不是在模块内部反复检查。

Rust 备注:256 元素查表用 const 数组即可编译期生成(或用 std::array::from_fn/LazyLock 运行期填充)。位域可用 bitflags crate 或手工位操作表达。

把昂贵的计算移出循环

Rust 备注:把循环不变量提到循环外的局部变量即可(编译器有时也能自动做到,但显式提取更保险,也能减少 bounds check)。

推迟昂贵的计算

Rust 备注:延迟计算可用 std::sync::LazyLock(全局/静态)或 once_cell::OnceCell / Cell<Option<T>>(实例级)来惰性求值,仅在首次访问时计算一次。

特化代码

某个对性能敏感的调用点,未必需要通用库所提供的全部通用性。在这种情况下,如果能带来性能提升,可以考虑编写专门的特化代码,而不是调用通用代码。

Rust 备注

  • 热路径上避免 format!(会分配新 String),改用 write!(&mut reused_string, …) 写入可复用缓冲区,或直接用整数 to_string/手工拼接。
  • 前缀匹配用 str::starts_with / slice::starts_with 取代正则。
  • 编译期常量分派可用泛型 + const 泛型参数或 #[inline] 让常量传播生效,等价于 VLOG 的按级别特化。

用缓存避免重复工作

Rust 备注:缓存可用 HashMap<u64, Arc<Meta>> 配合 Mutex/RwLock,或用 memoize 类库;键采用预计算指纹(如 u64)避免对大对象重复哈希/解析。

让编译器的工作更轻松

由于编译器必须对代码整体行为做保守假设,或者可能没有在速度与体积之间做出正确权衡,它在穿透多层抽象进行优化时可能力不从心。应用程序员往往更了解系统行为,可以通过把代码改写到更低层次来帮助编译器。不过,只有在性能剖析确实显示出问题时才这样做,因为编译器通常自己就能做对。查看性能关键例程生成的汇编代码,有助于判断编译器是否 " 做对了 "。Pprof 提供了非常有用的源码与反汇编交错展示,并标注了性能数据。

一些可能有用的技巧:

  1. 在热函数中避免函数调用(让编译器省去栈帧建立的开销)。
  2. 把慢路径代码移入一个单独的、以尾调用方式调用的函数。
  3. 在密集使用前,把少量数据复制到局部变量中。这可以让编译器假定它与其他数据不存在别名,从而可能改善自动向量化与寄存器分配。
  4. 对非常热的循环进行手工展开(hand-unroll)。

Rust 备注

  • 把慢路径拆成 #[cold] #[inline(never)] 函数,等价于 " 移入单独的尾调用函数 "。
  • 在热用前把小块数据 let base = *slice_ref; 复制到局部变量,可减少别名假设、利于自动向量化与寄存器分配。
  • Rust 同样可以手工展开循环;chunks_exact(N) 是惯用写法(例如 for chunk in bytes.chunks_exact(16) { … },再用 .remainder() 处理尾部),保留原文 " 手工展开 " 的意图。注意:不要声称 CRC 的查表运算会自动向量化为 SIMD——查表本质上难以向量化,这里的收益来自减少循环开销与分支,而非 SIMD。
  • 把不可达的致命分支写成 debug_assert!(false, …) 配合 unreachable!(),可在 release 构建中避免昂贵的 panic/frame 建立路径。

降低统计收集的成本

要在统计等系统行为信息的价值与维护这些信息的成本之间做权衡。额外信息常常能帮助人们理解并改进系统的高层行为,但维护起来也可能代价高昂。

无用的统计可以彻底删除。

统计或其他属性常常可以只针对系统处理的元素样本来维护(例如 RPC 请求、输入记录、用户)。许多子系统采用这种做法(tcmalloc 分配跟踪、/requestz 状态页、Dapper 采样)。采样时,可在合适时降低采样率。

Rust 备注:无用统计直接删除;采样计数用 count & (N-1) == 0(N 为 2 的幂)取代取模,等价于原文的 "power-of-two modulus" 快速判定。热路径上的原子统计计数尽量避免,或改为按需/采样聚合。

避免在热代码路径上打日志

即使某条日志语句的日志级别并不会真正输出任何内容,日志语句也可能代价高昂。例如 ABSL_VLOG 的实现至少需要一次加载和一次比较,这在热代码路径上可能成为问题。此外,日志代码的存在还可能抑制编译器优化。可以考虑从热代码路径上彻底移除日志。

Rust 备注:使用 log / tracing 时,用 log_enabled!(Level::Debug) / tracing::enabled!(…) 在嵌套循环外预计算一次并复用;并通过编译期最大日志级别(如 logmax_level_* feature 或 tracingmax_level_*)在 release 构建中彻底剔除热路径上的日志代码,避免运行期加载与比较开销。

代码体积考量 (Code size considerations)

性能不仅仅是运行速度。有时值得考虑软件选择对生成代码体积的影响。代码体积过大意味着更长的编译与链接时间、臃肿的二进制文件、更多内存占用、更大的指令缓存压力,以及对分支预测器等微架构结构的负面影响。在编写会被大量复用的底层库代码,或编写预期会针对许多类型实例化的模板代码时,尤其需要留意。

降低代码体积的技巧因语言而异。以下是一些对 C++(容易过度使用模板与内联)行之有效的做法。

精简被广泛内联的代码

被广泛调用的函数一旦与内联结合,会对代码体积产生显著影响。

Rust 备注:宏与 #[inline] 的膨胀效应同样存在。把错误格式化路径抽成独立的非内联函数(#[cold] / #[inline(never)]),只让快路径保持内联。

谨慎内联

内联常能提升性能,但有时只会增大代码体积却没有相应回报,甚至因指令缓存压力上升而变慢。

减少模板实例化

模板代码会针对模板参数的每种组合被重复实例化。

Rust 备注:泛型单态化会为每组类型参数生成一份代码。把与类型无关的臃肿逻辑抽成非泛型内核函数,让泛型外壳只做薄薄的转发。

减少容器操作

留意 map 等容器操作的影响——每次这类调用都可能产生大量生成代码。

并行化与同步 (Parallelization and synchronization)

利用并行

现代机器核心众多,却常被闲置。因此昂贵的工作可以通过并行来更快完成。最常见的做法是并行处理不同项并在完成后合并结果;通常先把项分批,以摊薄逐项并行的开销。

系统层面的效果应仔细度量——若没有空闲 CPU,或内存带宽已饱和,并行化可能无助甚至有害。

Rust 备注:CPU 密集的批处理优先用 rayonpar_iter();它自带工作窃取与结果合并,改造成本低。

摊薄锁获取

避免细粒度加锁以降低热路径上 Mutex 操作的开销。注意:仅当此改动不会加剧锁竞争时才可采用。

Rust 备注:对应做法是在递归入口获取一次锁,内部走 " 已持锁 " 的私有方法,避免对子树每个节点重复 lock。

保持临界区简短

避免在临界区内做昂贵工作。尤其警惕那些看似无害、实则可能发起 RPC 或访问文件的代码。

此外要警惕在 Mutex 解锁前运行的昂贵析构函数(常由 ~MutexUnlock 触发);把带昂贵析构的对象声明在 MutexLock 之前可能有帮助(前提是线程安全)。

Rust 备注:在锁内 copy-out 所需数据,然后在做 I/O 前显式 drop(guard);且切勿跨 .await 持有 std::sync::Mutex

通过分片减少竞争

有时一个受 Mutex 保护、竞争激烈的数据结构可以安全地拆成多个分片,每个分片各持一把 Mutex(前提是分片间没有跨分片不变式)。

若相关数据结构是 map,可考虑改用并发哈希表实现。

Rust 备注:per-key 并发 map 用 dashmap;或自建 Vec<Mutex<Shard>> 并按 key 哈希选片。

SIMD 指令

探索用现代 CPU 上的 SIMD 指令一次处理多个数据项能否带来提速(例如后文 Bulk Operations 一节中关于 absl::flat_hash_map 的讨论)。

Rust 备注:可用 std::arch 内建函数(需 unsafe 与目标特性)、可移植的 std::simd(nightly),或第三方 wide crate。

减少伪共享

若不同线程访问不同的可变数据,考虑把这些数据项放到不同缓存行,例如 C++ 中用 alignas 指令。但这类指令容易误用,还可能显著增大对象体积,务必用性能度量来证明其必要性。

Rust 备注:用 #[repr(align(64))] 对齐结构体,或用 crossbeam_utils::CachePadded<T> 包裹热点字段。

减少上下文切换频率

使用带缓冲的通道做流水线

通道可以是无缓冲的,这意味着写入方会阻塞直到读取方准备好取走一项。无缓冲通道在用于同步时很有用,但在用于提升并行度时并不合适——若要靠通道构建流水线以增加并行,应使用带缓冲的通道。

Rust 备注:带缓冲通道用 std::sync::mpsc::sync_channel(n)crossbeam-channel 的 bounded(n);容量 n 决定写入方在阻塞前可积压多少项。

考虑无锁方法

有时无锁数据结构相较于传统的互斥锁保护结构能带来差异。但直接操作原子变量可能很危险,应优先选用更高层的抽象。

Rust 备注:per-key 的读多写少 map 用 dashmaparc-swap 只适合对整个值做读多写少的原子替换,并不适合作为 per-key map。

Protocol Buffer 建议 (Protocol Buffer advice)

Protobuf 是数据的便捷表示形式,尤其适合通过网络传输或持久化存储的数据,但可能带来显著的性能代价。例如:一段填充 1000 个点、再对 Y 坐标求和的代码,从 protobuf 改为 C++ std::vector of structs 后,速度提升达 20 倍(基准 BenchmarkIteration 从 17.4µs 降到 0.8µs,-95.30%)。此外 protobuf 版本还会给二进制增加几 KB 的代码与数据,累积起来造成 i-cache/d-cache 压力。

Rust 特定建议(对应原文 C++-Specific advice)

absl::flat_hash_map(及 set)→ std HashMap

Abseil 哈希表通常胜过 std::mapstd::unordered_map。示例:LanguageFromCode__gnu_cxx::hash_map 换成 absl::flat_hash_map,基准 BM_CodeToLanguage 从 19.4ns 降到 10.2ns(-47.47%);stats publish/unpublish 与 SelectServer alarm 表也从 hash_map 换成 dense_hash_map(今天会用 flat_hash_map)。Rust 对应:标准库 HashMap(基于 hashbrown,采用 SIMD 探测的 Swiss Table)。

absl::btree_map / absl::btree_set → BTreeMap / BTreeSet

每个树节点存多个条目:减少指向子节点的指针开销,且同节点的键值连续存放、cache 效率更佳。示例:把一个高频使用的 work-queue 从 std::set<WorklistItem> 换成 absl::btree_set<WorklistItem>

util::bitmap::InlinedBitVector → bitvec / fixedbitset

可把短位向量内联存储,常优于 std::vector<bool> 等位图类型。示例:把 vector<bool> live_reads(nreads) 换成 util::bitmap::InlinedBitVector<4096>,并用 FindNextSetBit 直接跳到下一个置位项,避免逐位遍历。

absl::InlinedVector → SmallVec / ArrayVec

把少量元素内联存储(数量由第二个模板参数配置),小向量可获得更好 cache 效率并完全避免分配 backing 数组。示例:把 std::vector<InstructionRecord> instructions_ 换成 absl::InlinedVector<InstructionRecord, 2>

gtl::vector32 → Rust 无直接对应

使用只支持 32 位大小的定制 vector 类型来省空间。示例:std::vector<FamilyId> 换成 gtl::vector32<FamilyId>(读取接口改用 absl::Span),该简单类型改动在 Spanner 省下约 8TiB 内存。Rust 无直接对应:用 u32 索引替代。

gtl::small_map → Vec<(K, V)> 线性查找

用内联数组存放至多若干个唯一键值对,超容量后自动升级为用户指定的 map 类型。示例:gtl::flat_hash_map<int, TFLiteContext*> 改为 gtl::small_map<gtl::flat_hash_map<int, TFLiteContext*>>。Rust 对应:小规模用 Vec<(K, V)> 线性查找。

gtl::small_ordered_set → 有序 Vec + 二分查找

关联容器(如 std::setabsl::btree_multiset)的优化:先用定长数组存一定数量元素,超容量后退回 set/multiset。对通常很小的集合,比直接用为大数据集优化的 set 快得多,缩小 cache 足迹并缩短临界区。示例:std::set<ParsedRtpTransport*> 换成 gtl::small_ordered_set<std::set<ParsedRtpTransport*>, 10>。Rust 对应:有序 Vec + 二分查找。

gtl::intrusive_list → intrusive-collections crate 或 Vec+ 索引

链接指针内嵌在类型 T 的元素中的双向链表;相比 std::list<T*>,每个元素省一条缓存行 + 一次间接。示例:把 std::set<int64> inflight_requests_ 换成继承 gtl::intrusive_link<SeqNum>SeqNum 元素 + gtl::intrusive_list<SeqNum>。Rust 对应:intrusive-collections crate,或 Vec + 索引。

限制 absl::Status / absl::StatusOr 使用 → Result / Option

即便 absl::Status/StatusOr 相当高效,成功路径上仍有非零开销,故热点函数若无需返回有意义的错误细节(甚至永不失败)应避免使用。保留四个示例:

Rust 备注:FxHashMap/ahash 等更快的 hasher 可显著加速,但它们不抗 HashDoS——仅在输入可信时使用。

批量操作 (Bulk operations)

尽量一次处理多个条目,而非逐个处理。

综合运用多种技巧的 CL (CLs that demonstrate multiple techniques)

有时一个 CL 会包含多项性能改进,同时用到前文介绍的许多技巧。在某个部分被确认为瓶颈之后,研读这类 CL 中的改动方式,往往是培养 " 如何系统性地为系统某部分提速 " 这种思维方式的好途径。

扩展阅读 (Further reading)

以下是作者们觉得有帮助的性能相关书籍与文章,排名不分先后:

Rust 侧补充:The Rust Performance Book(nnethercote),以及 rayoncriterionhashbrownbumpalosmallvec 等 crate 的文档。

建议引用 (Suggested citation)

如果你想引用本文档,我们建议:

Jeffrey Dean & Sanjay Ghemawat, Performance Hints, 2025, https://abseil.io/fast/hints.html

或使用 BibTeX:

@misc{DeanGhemawatPerformance2025,
  author = {Dean, Jeffrey and Ghemawat, Sanjay},
  title = {Performance Hints},
  year = {2025},
  howpublished = {\url{https://abseil.io/fast/hints.html}},
}

(说明:以上为原文档上游的署名与引用信息,按原样保留。)

致谢 (Acknowledgments)

许多同事为本文档提供了有益的反馈,包括: