LabHub
学习 学习路径 课程

计算机组成

用时间量一量局部性

在 LabHub 中继续学习

目标

亲自测量:即使读取相同数据、读取次数也完全一样,仅仅改变读取顺序,耗时就会相差数倍。完成步幅、行优先与列优先、分块三组实验后,局部性将不再是抽象概念,而会成为清晰可见的数字。

为什么重要

内存层级建立在程序具有局部性这一假设上。违背这个假设的代码无法获得硬件提供的任何帮助。缓存以 64 字节的缓存行为单位取数;预取器会提前读取按固定间隔前进的访问;TLB 则记住最近访问页面的地址转换。随着步幅增大,这三者会依次失效。

这里测量的不是 Python 的速度。Python 虽然慢,但其开销会同样附着在每个实验上;只要固定读取次数,剩下的差异就来自内存。这几乎就是本实验的全部设计。

绝对数值因机器而异。评分也不检查绝对值,只看哪一侧慢、慢了多少

步骤

  1. 确认运行此 Pod 的 CPU 缓存行大小和缓存层级,保存到 /root/mem/01-cache.txt
  2. 创建 /root/mem/stride.py。它接收步幅参数,并只输出一行表示单次读取纳秒数的数字。
  3. 测量步幅 1、16、65536,把三行结果保存到 /root/mem/03-stride.txt
  4. stride.py 增加 rand 分支,分别测量顺序访问与随机访问,把两行结果保存到 /root/mem/04-random.txt
  5. 创建 /root/mem/matrix.py。它接收 N 与遍历方向参数,并输出每个元素的纳秒数。
  6. N 设为 2048,测量行优先与列优先,把两行结果保存到 /root/mem/06-order.txt
  7. matrix.py 增加 block 分支,测量列优先与分块访问,把两行结果保存到 /root/mem/07-block.txt
  8. 分别用一句话总结三个实验,保存到 /root/mem/08-notes.md

参考

读取本机缓存层级

确认运行此 Pod 的 CPU 缓存行大小和缓存层级(L1/L2/L3),保存到 /root/mem/01-cache.txt。同时计算一行能容纳多少个 int32

缓存行大小位于 /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size,读取同一目录中的 leveltypesize 可以看到层级。int32 占 4 字节,因此用缓存行大小除以 4,即可得到每行容纳的元素数。这个数字是后续选择步幅的依据。

制作可改变步幅的测量工具

创建 /root/mem/stride.py。通过 python3 /root/mem/stride.py <보폭> 调用时,创建包含 24,000,001 个 int32 的数组(约 96MB),读取 500,000 次,并以一位小数、仅一行数字的形式输出每次读取的纳秒数。

关键是让读取次数不受步幅影响并保持固定,这样 Python 本身的慢速开销会在两侧同样抵消,只留下内存差异。

预先生成索引列表,计时区间内只遍历该列表。下一个索引通过 i = (i + 보폭) % 배열길이 移动。数组长度为奇数,因此即使步幅很大,也不会很快反复访问同一位置。

测量前先预读约 1,000 次进行预热。第一次运行会混入创建数组的成本。

以 1、16、65536 三种步幅测量

使用第 2 步的工具分别测量步幅 1、16、65536,并在 /root/mem/03-stride.txt 中按 보폭 나노초 格式保存三行。

可以像 for s in 1 16 65536; do echo "$s $(python3 /root/mem/stride.py $s)"; done 这样一次得到三行。

对于 int32,步幅 16 恰好跨过一个缓存行;步幅 65536 为 256KB,远远超出页面边界和预取器可跟随的范围。预取器无法跨越页面边界,因此这里的数值会显著升高。

保持大小不变,只改变访问顺序

修改 /root/mem/stride.py,使其在参数为 rand 时按随机顺序读取。随后分别测量顺序访问(步幅 1)和随机访问,在 /root/mem/04-random.txt 中保存 seq 나노초rand 나노초 两行。

本步骤的要点是保持数组大小和读取次数不变,只改变读取顺序。由于容量相同,差异不能归因于容量,剩下的解释只有空间局部性。

请在计时前预先生成随机索引。使用 random.Random(1) 之类的固定种子,可确保每次运行得到相同顺序。

制作按两个方向遍历矩阵的工具

创建 /root/mem/matrix.py。通过 python3 /root/mem/matrix.py <N> <row|col|block> 调用时,把 int32 N×N 存入一个扁平数组(a[i * N + j]),按指定方向遍历全部元素,并以一位小数、仅一行数字的形式输出每个元素的纳秒数。

rowi 为外层、j 为内层;col 则相反。两个分支的索引表达式都必须完全相同,都用 a[i * N + j]。如果只在一侧省去乘法,Python 运算成本会混入差值,最终无法判断测量的究竟是什么。

block 将在第 7 步使用。现在只实现 rowcol 也能通过本步骤。

测量行优先与列优先的差异

N 设为 2048,分别测量行优先与列优先,并在 /root/mem/06-order.txt 中保存 row 나노초col 나노초 两行。

读取的元素和次数完全相同,只有顺序不同,但列优先仍会慢数倍。N 为 2048 时,列方向相邻元素相隔 8KB,超过一个页面(4KB)的距离。

如果差异不明显,可增大 N。矩阵能够放进缓存时,两个方向的速度会接近。

通过分块减少列优先的损失

matrix.py 增加 block 分支。保持列优先顺序,但每次只在 64×64 的小块内遍历。随后将 N 设为 2048,分别测量列优先和分块访问,并在 /root/mem/07-block.txt 中保存 col 나노초block 나노초 两行。

外层两重循环移动小块的起始坐标,内层两重循环在块内按列优先遍历。一个 64 行 × 64 列的小块触及约 16KB 的缓存行,可以放进 L1。

读取相同元素、次数也相同,却能加速,是因为在丢弃已取回的缓存行之前充分利用了它。矩阵乘法使用分块的原因也是如此。

分别用一句话总结三个实验

/root/mem/08-notes.md 中至少写三行,分别说明增大步幅为何变慢、列优先遍历为何吃亏,以及分块恢复了什么。

三个实验从不同角度讲的是同一件事:内存以缓存行为单位移动,取回一行却没有使用就丢弃,会付出相应代价。

正文中必须包含 보폭열 우선타일