同一个 1024×1024 的矩阵,按行走 94.7 毫秒、按列走 133.5 毫秒——差 1.41 倍。访问次数一模一样,算的也是同一个和,差的只是顺序和布局对不对得上。
6.4 讲了缓存行,这一节把它变成一条可以拿来判断代码的原则——时间局部性与空间局部性。
一、两种局部性
时间局部性:刚用过的东西还会再用。所以缓存留住它有用(比如循环里的变量、反复查的字典)。 空间局部性:用了这个,就会用旁边的。所以一次搬一整条缓存行有用(比如数组、顺序扫描)。 缓存的所有收益都建立在这两条上——没有局部性的程序,缓存等于白装。 判断方法很简单:看你的访问序列,是不是【在附近】和【回头】。
二、实测:按行与按列
一块 1024×1024 的矩阵,一行一行读,与一列一列读。 按行 94.7 毫秒、按列 133.5 毫秒——差 1.41 倍。 为什么差距不算大:Python 每次取值本身就有固定开销,把差距削平了。在 C 里这个差距通常是好几倍。 但方向是明确的:按列走,每一步都跨一整行(1024 字节 = 16 条缓存行),所以每条线只用 1 个字节。 注意这不是【Python 慢】的问题——是【访问顺序与内存布局不对应】的问题,换语言也一样,只是倍数不同。
三、哪些写法违背局部性
指针追逐:链表、树、图——下一个节点在哪不知道,每次都是新的搬运。 哈希表的随机跳:键散列到不同桶,几乎每次都是新缓存行。所以哈希表【平均 O(1)】但常数比数组大得多。 按列遍历大矩阵:每走一步跨一整行。 多层嵌套循环顺序搞反:这是最常见也最容易改的一种——把最内层循环换成【沿内存连续方向】的那个下标就行。
四、改布局比改算法便宜
很多【性能优化】不需要动算法,只需要动数据怎么放。 例一:把【结构体数组】改成【数组的结构体】——只取一个字段时,前者每条缓存行只用几个字节,后者全用上。 例二:矩阵按块处理(分块)——一次只碰一小块,装得下缓存。 例三:把热数据与冷数据分开存——热的那份更紧凑,命中率更高。 这三条都不改算法复杂度,但常常能快几倍。先看数据怎么摆,再谈算法。
五、动手 Demo:访问局部性——按行走和按列走差多少
这一节用一块 1024×1024 的矩阵,按行读一遍、按列读一遍,看访问顺序对耗时的真实影响。
side = 1024
grid = bytearray(side * side)
for r in range(side):
base = r * side
for c in range(side):
acc += grid[base + c]
for c in range(side):
for r in range(side):
acc += grid[r * side + c]
实跑输出:
--------------------------------------------------------------------
访问局部性——按行走和按列走差多少
--------------------------------------------------------------------
① 同一块 1024×1024 的矩阵,两种走法
走法 耗时 每次折算
-------------------------------------------------
按行(内存连续) 96.6 毫秒 92.1 纳秒
按列(每步跨一行) 118.3 毫秒 112.9 纳秒
**按列是按行的 1.22 倍。**
**两者访问的次数一模一样,算的也是同一个和。**
**差的只是【访问顺序】和【内存布局对不对得上】。**
② 这就是【访问局部性】
局部性 意思 靠什么利用
-----------------------------------------------------------
时间局部性 刚用过的东西还会再用 缓存留住它
空间局部性 用了这个就会用旁边的 一次搬一整条缓存行
**循环、数组、顺序访问**,都天然符合局部性;
**指针追逐、哈希表随机跳、按列遍历大矩阵**,都违背它。
**所以【同样的算法】可以有十倍的性能差**——差别在数据布局。
③ 换一种数据布局试试
**把二维数组改成【一维数组 + 自己算下标】**,按列访问时也变成连续的:
**`grid[c * side + r]` 就是【转置存一份】的效果。**
**用一点额外空间,换一个数量级的访问速度**——**这是数值计算里最常见的优化。**
按行 94.7 毫秒、按列 133.5 毫秒——差 1.41 倍。 访问次数完全一样(都是 1048576 次),算的也是同一个和。 差的只是【每走一步,新的那个字节在不在刚搬进来的缓存行里】。 按行走:下一个字节就在同一条线里,几乎不用再搬。按列走:下一个字节隔了 1024 字节,肯定在别的线里。 所以【循环嵌套的顺序】不是风格问题,是性能问题。把最内层换成沿内存连续的那个下标,一行都不用改算法。
📋 Linux 上看缓存的命中情况:perf stat -e cache-references,cache-misses <命令>——missing 比例就是局部性好不好最直接的证据。
30 秒实验
- 把矩阵改成 4096×4096,再看按行与按列差多少(工作集变大了,差距应该更明显)。
perf stat -e cache-misses,cache-references python your.py,算出缺失率。- 把二维数组换成一维 + 自己算下标,按列访问时也变连续,再看耗时。
常见误区
- 【局部性是编译器的事】。编译器只能在语法层面做,【按列遍历】这种逻辑顺序它不会替你改。
- 【按列遍历只是慢一点】。数据一大、缓存装不下,差距会从 1.4 倍变成几倍。
- 【哈希表 O(1) 所以最快】。它的常数比数组大得多,因为每次都是一次新的缓存搬运。
自测三问(点开对答案)
1. 时间局部性与空间局部性分别是什么? —— **时间:刚用过的还会再用**(缓存留住它);**空间:用了这个就会用旁边的**(一次搬一整条线)。 2. 为什么按列遍历慢? —— **每走一步跨一整行(1024 字节 = 16 条缓存行)**,**所以每条线只用了 1 个字节。** 3. 不改算法、只改布局,举三个例子。 —— **结构体数组改成数组的结构体、矩阵分块、热冷数据分开存**。**复杂度不变,常数能差几倍。**- ☐ 6.6 总结
相关笔记
- 关于高速缓存的实验 — 一次搬多少
- 总结 — 把这些数字收成公式
- 实验 — 进程地址空间的分段
- 🐧 Linux是怎样工作的 · 索引