跳转到主要内容

H.5 编译器推测的硬件支持

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

要积极开展推测,需要三项能力:

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

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

保持异常行为的硬件支持

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

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

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

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

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

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

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

例题 考虑以下 if-then-else 语句,其中 A 位于 0(R3),B 位于 0(R2):

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

其代码片段为:

      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 未被使用,可以分配。

解答 新代码如下:

      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 分支也可以结合条件传送进行推测,但如果分支高度可预测而且代价低,这样可能更慢,因为会从执行一条分支变为始终执行两条额外指令。

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

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

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

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

解答 代码如下:

      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 标记,就触发陷阱。

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

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

      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,就触发异常。1

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

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

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

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

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

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

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


  1. 译注:原书此例的代码使用 sLD、SD,而注释和随后文字混用了 LW、SW。这里按实际代码指令称为“推测加载”和 SD,避免把字加载/存储与双字加载/存储混为一谈。 ↩︎