3388 字
17 分钟

访存、计费粒度与并行度:性能优化通用方法论

本文刻意写成与项目无关的形式,供后续开发与其他语言参考。

怎么用这份文档#

  • 接手新模块 / 发现热点 → 走 §3 的五个审计与决策树
  • 写代码评审意见 → 对照 §5 反模式清单
  • 怀疑”是不是内存问题” → 先看附录 B 的判定方法,别凭直觉
  • 想找同类案例 → 附录 A 的八条,每条都标注了它违反哪条判据

0. 结论先行#

性能问题的根源极少是”访问顺序”,而是 ①每元素摊销的访存次数、②访存之间的依赖关系、③每个付费单位的工作量。 顺序访问之所以常常有效,是因为它同时改善这三者;但如果热表能装进 L1、且访存彼此独立,随机查表同样快,甚至更快。

一句话操作原则:让访存可预测、批量、且彼此独立。做不到时,改算法,而不是改循环。

1. 三条判据(+ 一条补充)#

判据 1:访存地址是否提前可知(可预测性)#

不可预测可预测
形态链表 / 树 / 哈希链 / next 指针数组下标、由寄存器算出的偏移
硬件预取器无所适从顺序流可预取;表在 L1 则无需预取
后果只能等可以提前发射

判据 2:访存之间是否互相独立(并行度 / MLP)#

延迟本身不是问题,串行延迟才是。

串行链(延迟受限):
load(a) → 由 a 算出 b → load(b) → 由 b 算出 c → load(c) …
总耗时 = 链长 × 单次访存延迟
独立访存(吞吐受限):
load(t[i1])、load(t[i2])、load(t[i3]) … // 下标之间无依赖
总耗时 ≈ 访存数 × 吞吐 / 并行度

乱序执行能重叠独立访存,但对依赖链无能为力。这条解释了两类典型问题:

  • 数据结构引入的串行:unordered_map 的”桶数组 → 节点”是两级依赖访存;树/链表逐节点跳同理。
  • 算法本身串行:流密码的 CFB 模式每块密文依赖上一块,天然串行;CTR 模式的密钥流块彼此独立,可以整批交给硬件。这是换算法暴露并行度,不是换访存方式。

判据 3:每个”付费单位”做了多少有效工作(计费粒度)#

看热循环的趟数按什么单位计费,以及能否提高一档:

比特 → 符号 → CPU 字 → 缓存行 → 批次 → 系统调用 / 跨模块调用
反例趟数正例趟数
每比特一次移位合并8 趟/符号每符号一次 64 位合并1 趟/符号
每字节一次函数调用16 次/轮一条 SIMD 指令处理 16 字节1 次/轮
每 16 字节一次系统/库调用52 万次/8MB每 256KB 一次32 次/8MB

提高计费档位几乎总是免费收益,而且跨语言普适:Python 里”把循环推给 numpy”、SQL 里”别写 N+1 查询”、JS 里”别在回调里做重活”,都是同一条。

补充判据 4:热数据总量 vs 缓存层级#

工作集归属策略
≤ ~32 KBL1平坦表直接随机查
≤ ~1 MBL2平坦表仍可,注意冲突
数 MBL3分块(tiling)提升时间局部性,或改顺序流
≫ L3内存 / 磁盘顺序流 + 预取是唯一出路

判据 4 是下一节”什么时候该用顺序访问”的答案所在。

2. 什么时候该用顺序(流式)访问#

满足以下全部条件时,顺序访问是默认选择#

  1. 数据总量 ≫ 缓存(必须反复从内存/磁盘搬)
  2. 每个元素恰好处理一次(一趟扫描出结果)
  3. 元素之间不需要串行依赖

典型场景:压缩/解压、哈希、解析、扫描、过滤、聚合、拷贝、编解码、ETL。

以下情况,平坦随机访问优于顺序访问#

情况原因
热表能常驻 L1/L2索引一次就够;顺序扫一遍反而增加总访存量
访问位置由已就绪的寄存器值决定访存独立,可重叠
”少量元素被反复访问”时间局部性 > 空间局部性(如高频符号的短码)

所以不要把结论记成”要用顺序访问”。准确的表述是:让访存可预测、批量、且彼此独立;做不到时改算法。

3. 通用方法论:五个审计 + 一棵决策树#

这套审计语言无关 —— 它问的是”数据放在哪、怎么走”,而不是”用什么语法”。

审计 A — 计费单位#

这个热循环按什么单位付费?能否提高一档?

自查:内层循环趟数 = O(什么)?有没有”每元素一次函数调用 / 虚调用 / 边界检查 / 加锁 / 分配 / 系统调用”?

审计 B — 依赖距离#

下一次访存的地址,是否要等上一次访存的结果?

  • 要等 → 拉长依赖距离(循环展开、多流交错、预计算地址),或换算法暴露并行度
  • 不用等 → 提高并行度(展开、SIMD、多累加器)

附带的反模式:多个逻辑流写到同一个地址会造成伪串行。例如频率统计若对所有输入只自增一个计数器,就退化成 load-modify-store 的串行链;多累加器分桶再合并可以打破它。

审计 C — 工作集#

热数据总量多少?L1 / L2 / L3 装得下吗?

装得下 → 平坦表直接查;装不下 → 分块(tiling)或顺序流。

审计 D — 间接层数#

一次逻辑操作要解几次指针?

unordered_map / map / 链表 / 树 / vector<vector<T>> / vector<unique_ptr<T>> / 引用数组 —— 每一个至少多一跳。目标:一次逻辑操作一跳到位。

审计 E — 批处理#

有没有”每元素一次昂贵操作”可以升格为”每批一次”?

系统调用、加锁、分配、跨模块调用、IPC、网络往返、数据库查询,全部适用。

决策树#

热循环里依次问:
① 我按什么单位付费? —— 能升档吗?(比特 → 符号 → 批)
② 下次访存地址依赖上次结果吗?
依赖 → 拉长依赖距离 / 换算法暴露并行
不依赖 → 提高并行度(unroll / SIMD / 多流)
③ 热数据 vs L1/L2?
装得下 → 平坦表随机查(别强行顺序)
装不下 → 分块 or 顺序流 + 预取
④ 一次逻辑操作解几次指针?≥2 → 改平坦数组 / SoA
⑤ 有没有每元素一次调用/锁/分配?能批处理吗?

4. 跨语言映射:哪些构造会偷偷引入间接层#

原理相同,差别只在”你能控制到什么程度”和”哪些语法糖在背后加跳”。

语言顺序/平坦的好形态会偷偷加跳的构造
C/C++平坦数组、SoA、std::array、连续 vectorunordered_map/map、链表、vector<unique_ptr<T>>、虚调用、shared_ptr 控制块
RustVec<T>&[T]、切片迭代器(优化后即顺序流)、SoABox<dyn Trait>Rc<RefCell<T>>HashMapVec<Box<T>>;链式 iterator 若中途 collect 会额外分配
C# / .NETT[](值类型)、Span<T>Memory<T>stackallocList<Class>(引用数组 = 指针追逐)、Dictionary<K,V>、LINQ 链的重复枚举、装箱
Javaint[]/byte[]、原始类型数组、ByteBufferHashMapArrayList<Object>Stream 装箱、引用跳转
Go[]T(值语义连续)、[]byte 流式map[]*Tinterface{} 装箱、channel 逐元素传递
JS / TSTypedArrayArrayBuffer、平坦数值数组对象数组、Map、megamorphic 属性访问(引擎无法内联 → 等价于指针追逐)
Pythonnumpy / array / memoryview + 向量化逐元素 fordict、对象列表(每个元素都是一个 PyObject 指针)
SQL / ORM集合操作、批量 INSERTJOINN+1 查询、逐行 round-trip

Python 那一行值得单独注意:解释器开销远超访存,所以”提高计费档位”的单位从 CPU 字变成了整个数组 —— 但原理完全相同。方法论能跨语言,是因为它讨论的量(摊销到每个元素的访存次数与间接层数)在任何语言里都是同一个。

同理,“批处理”在各语言里的形态:C/C++ 的 SIMD 与块缓冲、C# 的 Span 批处理、Python 的向量化、SQL 的批量写、网络层的请求合并 —— 都是同一原理的不同外观。

5. 反模式清单(代码评审可直接对照)#

#反模式违反替代
1热循环里用哈希容器按整数/枚举键查表D平坦数组直接索引
2结构体里放 vector/string 作为”值”,热循环逐元素取D定宽整数(uint32/64)或 SoA
3内层循环按比特 / 字符推进A按符号 / 字 / 批量推进
4每个元素一次函数调用(尤其跨编译单元、虚函数)A、E内联 / LTO / 批量接口
5每个元素一次系统调用、加锁、分配E攒批、池化、一次调用处理一批
6逐元素链表 / 树 / next 指针遍历B扁平化 + 索引数组;或改算法
7多个逻辑流争抢同一地址自增B多累加器分桶
8用”看起来顺序”的 API 但底层每次随机(如逐行查询)E、D批量接口
9先改代码再测先量瓶颈,改完重新量(瓶颈会转移)

6. 边界:什么时候不该改#

  1. 工作集本来就在 L1 里 → 强行顺序化可能增加总访存量。当每符号的固定开销大于原逐位走树的成本时,查表反而回退。
  2. 瓶颈会转移 → 加密内核提速一个数量级后,瓶颈会变成密钥流计数器生成与异或遍历,再优化密码本体已无意义。
  3. 抽象代价 → SoA / 平坦表会让 API 更难用、更易错。收益不显著时别做。
  4. 过早优化 → 必须先量,再改,改完再量。

7. 落地清单(贴在手边)#

热循环自查(10 秒版)
□ 内层循环按什么单位跑?能升一档吗?
□ 下次访存地址依赖上次结果吗?—— 依赖 = 延迟受限,优先解决它
□ 热数据多大?装得进 L1 就平坦表,装不进就分块 / 流式
□ 一次逻辑操作解几次指针?≥2 就扁平化
□ 有没有"每元素一次调用 / 锁 / 分配"?能批处理吗?
□ 改完瓶颈跑哪去了?(重新测)

附录 A:八个反模式与正解的对照#

八个案例没有任何一个是”把随机访问改成顺序访问” —— 全部落在审计 A/B/D/E 上。这正说明为什么不能把结论简化成”用顺序访问”。

#反模式违反判据正解实测倍数
1unordered_map<unsigned char, T> 按字节值查编码D,桶→节点 2 跳加堆分配第 3 跳平坦 code[256] + len[256],一跳1.86×
2结构体值里放 std::vector<uint8_t> 存码字,逐符号堆指针追逐D定宽 uint64 码字累计至 2.63×
3位合并逐位移位A,按比特计费64 位位缓冲整码字合并累计至 13.87×
4频率统计用 unordered_map[c].freq++D 加 B,同址自增伪串行平坦 freq[c]++均匀数据 657 → 4022 MB/s;偏斜数据因同址自增只到 866 MB/s,正是判据 B 的体现
5解码逐位走树B 串行依赖链加 A换算法:canonical 码 + 12 位查表2.07×
6解码时每字节把位展开成 8 元素容器A 加内存放大 8 倍64 位位缓冲按字节冲刷含在 #5 内
7自研分组密码:逐字节函数调用加模运算,且无 SIMD 路径A 加 D 加无硬件路径换算法与换实现:CTR 暴露并行,交由硬件批量20.1× 与 20.6×
8热点函数定义在另一编译单元,无 LTOE,每符号一次跨 TU 调用-flto 内联编码 +44%

端到端提升通常远小于内核级提升(编码 7.5×、加密 20×,端到端只有 2 倍上下)。因为完整流程里还有目录枚举、逐文件读写、队列同步这些不随内核加速的串行端点 —— 这是 Amdahl 定律。优化内核之后必须重新测端到端,否则会高估收益。

附录 B:先判定你是哪一类瓶颈#

不做判定就套方法论,等于猜。三类瓶颈的解法完全不同:

类型特征指标解法
延迟受限IPC 低、stalled-cycles 高、cache-miss 高、依赖链长判据 1/2:扁平化、拉长依赖距离、换算法
带宽受限访存吞吐接近硬件上限、IPC 尚可减少总访存量(提高计费档位、避免重复读)、分块
计算受限IPC 高、访存不饱和判据 3:SIMD、更少指令、换算法

快速判定法:

  1. 数一数热循环每个元素访问了几次内存、几个缓存行 → 乘以元素数,与硬件带宽比较。差距大 → 带宽不是瓶颈。
  2. 检查是否单条依赖链 → 若是,无论带宽多大都受限。
  3. 做消融实验:只改一件事,测增量。这比读指标更直接,附录 A 的实测倍数就是这么来的。

工具:Linux perf stat / perf c2c、Intel VTune、AMD uProf、llvm-mca(静态估算)、编译器的 -Rpass-analysis / -fopt-info。Windows 上可用 ETW / WPA、Visual Studio 的 CPU 与内存分析器。

最可靠的判据仍是消融实验:把怀疑的那一项单独改掉,其他不动,前后对比。


项目介绍:SFC | 开发日志:SFC | 性能报告:v2.1.0 / v2.2.0 / v2.2.1

访存、计费粒度与并行度:性能优化通用方法论
https://www.yonagi.world/posts/performance-optimization-methodology/
作者
YONAGI
发布于
2026-09-12
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
遠い日に想いを馳せて
Laplacian
封面
遠い日に想いを馳せて
Laplacian
0:00 / 0:00