← 揭开黑盒:理解系统的调查课堂

第二章 · 计算机选择的数学 / 第六单元 · 计算与搬运

乘法之外,机器还在忙什么?

回到章首的矩阵。边长为 n 的两张表,要形成 n² 个结果,每个结果包含 n 个乘积。我们的三个实现都做 n³ 次乘法。按这个账本,它们似乎没有区别。

预计用时:40 分钟。打开 数据的旅程,先用 4×4,走几次单步,再看 8×8 的完整账本。

从坐标走到位置

本实验用一段连续的 Float64Array 保存一张矩阵。它按行排列:第一行接着第二行。第 i 行、第 j 列的位置是 i*n+j,每个元素占 8 字节,行列从零开始数。

4×4 时,第一行位于索引 0、1、2、3;第一列位于 0、4、8、12。读取一行和读取一列,需要走过不同的地址序列。

“连续”指这个 typed array 的数据区。普通 JavaScript 对象数组或 Python 的列表有其他表示细节,不能把相同下标直接当成同一种物理布局。

数据不只住在一个地方

当前主流 CPU 在执行单元附近有寄存器与缓存,再向外访问更大容量的内存。不同层的容量、访问延迟和带宽不同。一个值如果已经在附近,后面的工作可能复用它;如果不在,就要取得它。

缓存通常以一整行数据为搬运单位,程序使用其中一个元素时,相邻元素也可能被带来。这让连续访问和重复访问有机会减少远处的数据请求。具体缓存行大小、层数和替换方式随硬件而异,本课先使用一个能完全看清的模型。

给模型一个公开的约定

实验规定每行容纳 2 个数,A/B/C 分别从缓存行边界开始;缓存可容纳指定数量的行。访问未在缓存中的行时,装入它;容量满时移出最久未使用的一行。写未命中的 C 也先装入对应行。这叫本实验的全相联 LRU、写分配模型。

逐格实现把某个 C 的部分和留在局部变量,完成后才写 C;沿行和分块实现按源码反复更新 C。因此三者的数组访问数不完全相同,模型会把这个差别如实计入。

暂不统计预取、脏行写回、多级缓存与编译器优化。动画播放的 180 毫秒一步是为了阅读,不能当成硬件访问时长。

为什么要把命中与装入分别计数

提出两个解释:“数组访问次数越少,运行一定越快”;“相同的一次访问,命中与未命中的代价可能差很多”。固定 8×8、缓存 8 行、块边长 2,只改变顺序。

  1. 在自己的预测后核对完整模型账本。
    看答案

    参考:逐格、沿行、分块分别做 512 次乘法;数组访问为 1088、1600、1792;装入缓存行为 832、576、288。更多数组访问可以与更少装入并存,因为访问了已经在附近的数据。这些数字来自指定模型,尚未证明真实耗时排序。

再把缓存增加到 96 行。三张表合计恰好占 96 行,三种实现都只需首次装入的 96 行。刚才的差别消失,支持的是“容量与复用距离相关”的解释,而不是“分块在所有地方都快”。

本节产物

任选 C 的一个格子,用单步或滑块跟踪它需要哪些 A、B 值。写出一次缓存装入、一次命中和一次移出的具体行编号。之后改变一个条件,让自己的原预测不再成立,并解释原因。

到这里可以停止对真实 CPU 的追查:我们已理解一种可能的机制,但还不知道本机执行时间中它占多少。下一节先 把这种复用安排写成代码,再进行实际计时。