# H.5 编译器推测的硬件支持
<!-- source: p1156 -->

如第 3 章所述，许多程序的分支可以依据程序结构或性能剖析信息，在编译时准确预测。此时，编译器可能希望通过推测改善调度或提高发射率。谓词指令是一种推测手段，但更适用于通过 if 转换完全消除控制依赖的场景。很多时候，我们希望把推测指令不仅移到分支之前，还移到条件求值之前，而谓词化无法做到这一点。

要积极开展推测，需要三项能力：

1. 编译器能找出可以推测性移动、又不影响程序数据流的指令，必要时结合寄存器重命名。
2. 在确认异常确实应该发生之前，能够忽略推测指令的异常。
3. 能够推测性交换可能存在地址冲突的加载与存储，或两条存储。

第一项是编译器能力，后两项需要硬件支持，下面逐一讨论。

<!-- source: p1157 -->

## 保持异常行为的硬件支持

要积极推测，就必须能够移动任意类型的指令，同时保持其异常行为。关键观察是：如果推测路径预测错误，其结果不会用于最终计算，这些推测指令也就不应触发异常。

研究人员考察过四种方法，使更积极的推测不致引入错误的异常行为：

1. 硬件与操作系统协作，忽略推测指令的异常。这能保持正确程序的异常行为，却不能保持错误程序的异常行为。有些程序可能无法接受，不过已有若干处理器把它作为由程序控制的“快速模式”。
2. 使用永不触发异常的推测指令，并加入检查，判断异常何时应当发生。
3. 推测指令导致异常时，在其写入的结果寄存器上附加称为 **poison 位**的状态位。普通指令试图使用这样的寄存器时，poison 位触发故障。
4. 提供指令推测标记；硬件暂存其结果，直到能够确定该指令不再处于推测状态。

为解释这些方法，必须区分两种异常：一种表示程序错误，通常导致终止，例如存储器保护违规；另一种可以处理并恢复执行，例如缺页。对于推测指令，可恢复异常可以像普通指令一样接收和处理。如果该指令本不该执行，处理多余异常可能损害性能，却不会使执行结果出错。然而，其代价可能很高，因此一些处理器提供硬件支持来避免立即处理这些异常。支持硬件推测的处理器也是如此：可以在推测状态下处理某些异常，而将另一些推迟到确认指令不再是推测执行时处理。

表示程序错误的异常不应出现在正确程序中；除调试模式等情况外，发生这类异常的程序，其结果没有良好定义。如果推测指令引发这类异常，必须等到确认该指令不再处于推测状态，才能真正触发异常。

保持异常行为的最简单方法是：可恢复异常一旦发生，硬件和操作系统就处理它；对于会导致终止的异常，则只返回一个未定义值。如果触发终止类异常的指令并非推测指令，程序本身就是错误的。

<!-- source: p1158 -->

注意，这时程序不会终止，而会继续运行，虽然几乎肯定产生错误结果。如果该指令是推测指令，程序仍可能正确，推测结果只是不被使用；因此返回未定义值不会造成伤害。无论推测多么积极，这种方法都不会使原本正确的程序失败。原先可能因异常终止的错误程序，则会得到错误结果。对某些程序而言这是可接受的，前提是编译器还能够生成不作推测、可以正常因异常终止的普通版本。

**例题**　考虑以下 if-then-else 语句，其中 A 位于 `0(R3)`，B 位于 `0(R2)`：

~~~c
if (A==0) A = B; else A = A+4;
~~~

其代码片段为：

~~~asm
      LD    R1,0(R3)    ; 加载 A
      BNEZ  R1,L1       ; 检查 A
      LD    R1,0(R2)    ; then 分支
      J     L2          ; 跳过 else
L1:   DADDI R1,R1,#4    ; else 分支
L2:   SD    R1,0(R3)    ; 存储 A
~~~

假设几乎总是执行 then 分支，使用编译器推测重新编译这段代码。R14 未被使用，可以分配。

**解答**　新代码如下：

~~~asm
      LD    R1,0(R3)    ; 加载 A
      LD    R14,0(R2)   ; 推测加载 B
      BEQZ  R1,L3       ; 检查 if 的另一方向
      DADDI R14,R1,#4   ; else 分支
L3:   SD    R14,0(R3)   ; 非推测存储
~~~

整个 then 分支都被提前推测执行。临时寄存器避免了加载 B 时破坏 R1；若这次推测加载的结果不需要使用，R14 中的该结果就没有用处。整个片段执行后，A 的值位于 R14。else 分支也可以结合条件传送进行推测，但如果分支高度可预测而且代价低，这样可能更慢，因为会从执行一条分支变为始终执行两条额外指令。

这种方法并不需要知道某条指令是否是推测指令。标明这一点，主要有助于处理错误程序在普通指令上发生终止类异常的情况：如果该指令未标为推测，就可以终止程序。

<!-- source: p1159 -->

在这种方法和下一种方法中，通常都需要重命名，以防推测指令破坏仍然存活的值。重命名一般只针对寄存器值；由于这一限制，不能破坏存储指令的目的位置，因此不能推测执行存储。寄存器数量有限，以及寄存器溢出到存储器的开销，会限制推测规模。当然，主要约束仍然是编译器预测错误时执行推测指令的代价。

第二种保持异常行为的方法，引入不产生终止类异常的推测版本指令，以及检查此类异常的指令。两者结合，能够精确保留异常行为。

**例题**　使用推测加载 sLD 和推测检查指令 SPECCK，改写前例，完全保持异常行为。假定 R14 可用。

**解答**　代码如下：

~~~asm
      LD     R1,0(R3)   ; 加载 A
      sLD    R14,0(R2)  ; 推测加载，不终止程序
      BNEZ   R1,L1      ; 检查 A
      SPECCK 0(R2)      ; 执行推测检查
      J      L2         ; 跳过 else
L1:   DADDI  R14,R1,#4  ; else 分支
L2:   SD     R14,0(R3)  ; 存储 A
~~~

注意，推测检查要求保留一个代表 then 分支的基本块。如果只推测了 then 的一部分，这个基本块本来就必须存在。更重要的是，检查潜在异常需要额外代码。

第三种方法跟踪已发生的异常，却把终止类异常推迟到结果真正被使用时才触发。它保留异常的发生，但并非完全精确。方法很简单：为每个寄存器增加一个 poison 位，为每条指令增加一个表示是否推测执行的位。推测指令导致终止类异常时，置位目的寄存器的 poison 位；其他异常立即处理。若推测指令使用了 poison 位为 1 的寄存器，则把其目的寄存器的 poison 位也置为 1。若普通指令试图使用 poison 位为 1 的源寄存器，便触发故障。这样，原本会产生异常的程序仍会产生异常，只是触发点移到了非推测指令第一次使用该结果的位置。poison 位只附于寄存器值，不附于存储器值，因此存储从不推测执行，只要任一操作数带有 poison 标记，就触发陷阱。

<!-- source: p1160 -->

**例题**　考虑原书 H-29 页的代码片段，说明使用推测指令和 poison 位时如何编译，并指出在何处识别推测性存储器访问的异常。假设 R14 可用。

**解答**　操作码前的 s 表示推测指令：

~~~asm
      LD    R1,0(R3)    ; 加载 A
      sLD   R14,0(R2)   ; 推测加载 B
      BEQZ  R1,L3
      DADDI R14,R1,#4
L3:   SD    R14,0(R3)   ; 在这里触发推测加载的异常
~~~

如果 sLD 引发终止类异常，就置位 R14 的 poison 位。随后非推测存储 SD 执行时，若该位为 1，就触发异常。[^load-store-names]

还有一个必须解决的问题：上下文切换时，若 poison 位已置位，操作系统该如何保存用户寄存器？需要专用指令来保存和重置这些位的状态，才能避免此问题。

第四种方法依靠类似重排序缓冲区的硬件机制。编译器把指令标为推测，并指明它被提前移过了多少个分支，以及编译器为每个分支假设的方向——跳转或不跳转。后者实际上告诉硬件，指令原来位于哪个代码块。实践中，只允许跨越一个分支移动，就能得到大部分收益；因此，只需一位表示指令来自跳转路径还是不跳转路径。另一种办法是在推测指令的原位置留下**哨兵**（sentinel），通知硬件：此前的推测指令现在已不再具有推测性，可以提交结果。

所有指令发射时都进入重排序缓冲区，并强制按序提交，与硬件推测方法相同。不过，这里并没有实际的推测性分支预测或动态调度。缓冲区跟踪指令何时可以提交，并延迟推测指令的“写回”部分。只有该指令越过的分支也准备好提交，或到达对应哨兵时，推测指令才能提交。此时，硬件知道它是否本来就应该执行：如果应该执行且出现终止类异常，就应终止程序；如果本不该执行，就忽略异常。注意，寄存器重命名仍由编译器承担，以确保正确使用推测结果，并保证程序正确执行。

<!-- source: p1161 -->

## 存储器访问推测的硬件支持

通常，只有编译器确信地址不会冲突时，才把加载移过存储。3.2 节的例子已经表明，这类变换对于缩短代码片段的关键路径十分重要。要让编译器在不能完全确认移动正确时也这样做，可以在体系结构中加入检查地址冲突的专用指令。它留在加载的原位置，起到守卫的作用，而加载被提前移过一条或多条存储。

推测加载执行时，硬件保存访问地址。如果在检查指令之前，后续存储修改了该位置，推测就失败；若该位置未被修改，推测便成功。失败有两种处理方式。如果只推测了加载，那么在检查指令处重做加载就够了；检查指令除存储器地址外，还可提供目的寄存器。如果依赖该加载的后续指令也被推测，就需要一段修复代码，从加载开始重新执行所有推测指令。此时，检查指令指定修复代码的地址。

本节介绍了多种硬件辅助机制。它们是有效支持第 3 章和本附录中侧重编译器的方法的关键。此外，其中若干机制也容易融入第 3 章侧重硬件的方法，带来进一步收益。

[^load-store-names]: 译注：原书此例的代码使用 sLD、SD，而注释和随后文字混用了 LW、SW。这里按实际代码指令称为“推测加载”和 SD，避免把字加载/存储与双字加载/存储混为一谈。
