本篇位置 专门解决第 12 篇留下的账一:访存翻倍
运行环境 容器里的 Linux,gcc

一、账一:一次访问变两次

第 12 篇末尾那笔账:页表在内存里,所以每次访问内存都要先读一次页表,再读一次数据。慢一倍。

这个问题不能靠"把页表做得更好"解决——它在内存里,读它就要一次内存访问,没法更快。

唯一的出路是别每次都读它

打个比方

酒店前台那本对照表已经建起来了。现在每来一个人问"我的 307 房实际在哪",前台就要翻一次那本厚账本。

队伍立刻排到大堂外面去了。

前台的解法很朴素:在桌上放一张便签,把最近查过的十几条抄在上面。 下一个人来问,先扫一眼便签——在上面就直接答,不在才翻账本。

这张便签就是 TLB(Translation Lookaside Buffer,地址转换后备缓冲)。

⭐ 名字听起来很唬人,它就是页表的一个小缓存,做在 CPU 里,容量几十到几千条

二、它凭什么有用

便签只有十几行,账本有几百万行。凭什么这么小的东西能顶用?

凭程序访问内存的方式极不均匀:

空间局部性:你访问了一个字节,很可能马上要访问它旁边的。一页有 4096 字节——你顺序读一个数组,读 4096 个字节才需要换一次页表项。前 4095 次全部命中便签。

时间局部性:刚用过的东西很可能马上再用。循环体的代码、栈上的局部变量,反复访问的就那么几页。

⭐ 所以命中率的关键不是"你访问了多少数据",而是**“你在一小段时间里涉及了多少个不同的页”**。这个数量有个名字:工作集

TLB 只要装得下你的工作集就行。

三、跑一遍看看:TLB 装不下的时候

这个说法可以直接量。

下面这段 C 每次只读一个字节,但每次跨一整页——所以每一次访问都要用一个不同的页表项。然后逐渐增加涉及的页数:

const long PAGE = 4096;
char *buf = mmap(NULL, MAXP * PAGE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
for (long i = 0; i < MAXP; i++) buf[i * PAGE] = 1;    /* 先全部落实物理页 */

long list[] = {16,64,256,512,1024,2048,4096,8192,16384,65536};
for (unsigned k = 0; k < sizeof list/sizeof *list; k++) {
    long pages = list[k];
    long reps = 40L * 1000 * 1000 / pages;
    volatile long sink = 0;
    double t0 = now();
    for (long r = 0; r < reps; r++)
        for (long i = 0; i < pages; i++)
            sink += buf[i * PAGE];
    double dt = now() - t0;
    printf("  %8ld %9.1f MB %13.2f ns\n",
           pages, pages * PAGE / 1048576.0, dt / (double)(reps * pages) * 1e9);
}
  每次只读 1 个字节,每次跨一整页(所以每次都换一个页表项)
    页数 覆盖范围 每次访问耗时
        16       0.1 MB          0.43 ns
        64       0.2 MB          1.93 ns
       256       1.0 MB          1.70 ns
       512       2.0 MB          1.76 ns
      1024       4.0 MB          1.75 ns
      2048       8.0 MB          1.75 ns
      4096      16.0 MB          3.42 ns
      8192      32.0 MB          3.80 ns
     16384      64.0 MB          3.94 ns
     65536     256.0 MB          4.21 ns

这张表里,每一行做的事完全一样:读一个字节,加到一个变量上。读的字节数、加法次数,全都一样。

唯一的区别是涉及了多少个不同的页

看那个台阶:

  • 16 页:0.43 纳秒。全部命中最快的那一层 TLB。
  • 64 到 2048 页:稳定在 1.7–1.9 纳秒。装不进第一层了,但第二层还兜得住。
  • 4096 页开始:跳到 3.4 纳秒,然后一路爬到 4.2。第二层也装不下了,每次都要去内存里翻页表。

同样的一条指令、同样的数据量,慢了一倍。多出来的时间全部花在地址翻译上——一个字节都没多读。

这个台阶还顺便量出了这台机器的 TLB 容量:它的第二层 TLB 大约能覆盖 2048 到 4096 个页,也就是 8 到 16 MB。这个数你可以自己在任何机器上测出来。

四、TLB 未命中,谁去处理

便签上没有,得去翻账本。这一步由谁来做,历史上有两种答案:

硬件走表(x86、arm64):CPU 里有一个硬件电路,知道页表的格式,自己去内存里一级级查,查到就填进 TLB,然后重试那条指令。操作系统全程不参与——它只需要把页表按硬件规定的格式摆好,把根地址填进那个寄存器(第 4 篇打死程序的那条指令读的就是它)。

软件走表(早期 MIPS、SPARC):TLB 未命中就抛异常,跳进内核,由操作系统的一段汇编去查表并填 TLB。好处是页表格式随便你定;坏处是每次未命中都要陷入一趟,贵得多。

今天的主流是硬件走表,因为未命中太频繁,陷入内核的代价扛不住。

⚠️ 这里有个坑:处理 TLB 未命中的那段代码本身,会不会又触发 TLB 未命中?软件走表的系统必须小心地把这段代码和它用的页常驻在 TLB 里(wired entries),否则就是无限递归。

五、⭐ 上下文切换:便签全废了

现在有个大问题。

进程 A 的"虚拟页 100"和进程 B 的"虚拟页 100",指向完全不同的物理帧(这就是第 1 篇那个演示)。

便签上只记了页号,没记是谁的页号。 切换到 B 之后,如果 B 查到了 A 留下的那条,它就读到了 A 的内存——整个隔离作废。

最简单的办法是每次上下文切换就把整块便签擦掉(flush)。安全,但很贵:

  • 切换本身多了一笔开销。
  • 更贵的是切换之后:新进程跑起来时便签是空的,接下来几百上千次访问全部未命中,都要去翻账本。

⚠️ 第 4 篇说"上下文切换的隐性开销往往比显性开销更大",这就是那个隐性开销最主要的来源之一。

现代 CPU 的补救是给便签的每一行加一个"这是谁的"标记——x86 叫 PCID,arm64 叫 ASID。切换进程时只要换一下当前的编号,旧条目还留着,将来切回来还能用

⭐ 这是一个很典型的模式:在缓存里加一个"所属者"字段,就把"清空重来"变成了"共存"。 这门课后面还会见到同样的手法。

六、代价与取舍

TLB 换来了什么: 把第 12 篇那笔"访存翻倍"的账,从必然的 2 倍压到了实测的 1.0–1.1 倍(命中时)。没有 TLB,分页在性能上根本不可行。

它花了什么:

  • 芯片面积和功耗。TLB 要做成全相联或高相联度的查找,很贵,所以做不大——这就是上面那个台阶存在的原因。
  • 一个新的性能悬崖。工作集一超过 TLB 覆盖范围,性能就掉一个台阶,而你的代码看起来一个字没改。上面那张表就是这个悬崖的照片。

它放弃了什么: 性能的可预测性。 你的程序快不快,现在取决于一个你看不见、也没法直接控制的硬件缓存的命中率。

对付这个悬崖,实践中有两条路:

  • 改数据布局,让工作集变小、访问变连续。这是数据密集型程序优化的主线之一。
  • 用大页(huge page)。一页 2 MB 而不是 4 KB,同样条数的 TLB 能覆盖 512 倍的内存。数据库和 JVM 常这么干。代价是内部碎片变大,而且需要连续的物理内存——第 11 篇那个碎片问题,在这里又绕回来了。

七、小结

  • 第 12 篇的账一(访存翻倍)不能靠"优化页表"解决,只能别每次都读它
  • TLB 就是页表的一个小缓存,做在 CPU 里,容量几十到几千条。
  • 它有用是因为局部性:一页 4096 字节,顺序访问时绝大多数访问都落在同一个页表项上。⭐ 命中率取决于工作集里有多少个不同的页,不取决于数据量。
  • 实测:同一条指令、同样的数据量,工作集 2048 页时 1.75 纳秒,4096 页时 3.42 纳秒。多出来的时间全在地址翻译上。这张表还量出了这台机器的 TLB 覆盖范围大约是 8–16 MB。
  • 硬件走表是今天的主流;软件走表灵活但每次未命中都要陷入内核,太贵。
  • 上下文切换会让 TLB 作废,因为条目里没记"这是谁的页号"。这是第 4 篇说的"隐性开销"的主要来源。PCID/ASID 给每条加上所属者标记,把"清空重来"变成了"共存"。
  • 代价是多了一个看不见的性能悬崖。对付它靠改数据布局,或者用大页——而大页又要求连续物理内存,把第 11 篇的碎片问题绕了回来

思考题

  1. 第三节那张表里,16 页时是 0.43 纳秒,64 页时反而涨到 1.93。这中间发生了什么?(提示:TLB 通常不止一层,而且 CPU 里还有别的缓存。)
  2. 如果一个程序反复顺序扫描一个 1 GB 的数组,TLB 命中率高不高?如果它随机访问这 1 GB 呢?两者的差别有多大?
  3. 第五节说 ASID 让旧条目能留着。但 ASID 的位数是有限的(比如 8 位 = 256 个)。进程数超过这个数会怎样?
  4. 便签那个比方里,“大页"对应什么?(提示:想想前台怎么才能用同样的便签行数,覆盖更多的房间。)

延伸