C.4 流水线为何难以实现?
了解如何检测与解决冒险后,可以处理前面暂时避开的一些复杂问题。本节前半讨论异常情况:指令执行顺序以非预期方式改变;后半讨论不同指令集带来的挑战。
处理异常
流水线处理器更难处理异常,因为指令重叠执行,使得判断一条指令能否安全地修改处理器状态变得更困难。指令分成若干部分执行,经过几个周期才完成;与此同时,流水线中的其他指令可能触发异常,迫使处理器在某些指令完成前中止它们。在深入讨论问题与解法之前,需要了解可能发生的情况,以及体系结构对异常支持的要求。
异常类型与要求
不同处理器对正常指令顺序被改变的情况采用不同术语,如中断(interrupt)、故障(fault)、异常(exception),但用法不统一。本书用“异常”统称这些机制,包括:
- I/O 设备请求。
- 用户程序调用操作系统服务。
- 跟踪指令执行。
- 断点,即程序员请求的中断。
- 整数运算溢出。
- 浮点运算异常。
- 缺页,即页面不在主存。
- 未对齐的访存,如果体系结构要求对齐。
- 违反存储器保护。
- 使用未定义或尚未实现的指令。
- 硬件故障。
- 电源故障。
特指某一类时,使用更完整的名称,如 I/O 中断、浮点异常或缺页。
虽然统称异常,各类事件的重要特征决定了硬件应采取什么措施。异常要求可沿五个部分独立的维度分类:
- 同步或异步: 若每次使用相同数据和内存分配运行程序,事件总在相同位置发生,就是同步事件。除硬件故障外,异步事件来自处理器与存储器之外的设备。异步事件通常可等当前指令完成再处理,因此较容易处理。
- 用户请求或强制发生: 用户任务直接要求发生的,是用户请求事件。从某种意义上说,它们可预测,算不上真正的“意外”;之所以作为异常处理,是因为仍使用同一套保存和恢复状态的机制。触发此类事件的指令,其唯一功能就是产生异常,所以总能在该指令完成后处理。强制发生的异常由用户程序无法控制的硬件事件引起,因不可预测而较难实现。
- 用户可屏蔽或不可屏蔽: 用户任务能屏蔽或禁用的事件,称为用户可屏蔽。屏蔽位仅控制硬件是否响应异常。
- 指令内部或指令之间: 事件是在执行中途发生、阻止指令完成——无论这段执行多短——还是在指令之间被识别?指令内部异常通常是同步的,因为由指令触发。它们需要停止并重新启动指令,比指令间异常更难实现。指令内部的异步异常来自灾难性情况,如硬件故障,总会使程序终止。
- 恢复或终止: 若中断后程序总是停止,则为终止事件;若继续执行,则为恢复事件。终止异常较容易实现,因为处理器不必在处理后重新启动同一个程序。
图 C.26 按这五个维度对前面的例子分类。最难实现的,是发生在指令内部、又必须恢复执行的中断。处理它时,必须调用另一个程序,保存正在执行的程序状态、纠正异常原因、恢复状态,然后重试触发异常的指令。这一过程实际上必须对原程序不可见。若流水线允许处理器处理异常、保存状态并重新启动,同时不影响程序执行,就称该流水线或处理器可重新启动(restartable)。早期超级计算机与微处理器常缺少这一属性,但如今几乎所有处理器至少对整数流水线都提供它,因为实现虚拟内存需要这一能力(见第 2 章)。
| 异常类型 | 同步/异步 | 用户请求/强制发生 | 用户是否可屏蔽 | 指令内部/之间 | 恢复/终止 |
|---|---|---|---|---|---|
| I/O 设备请求 | 异步 | 强制发生 | 不可屏蔽 | 之间 | 恢复 |
| 调用操作系统 | 同步 | 用户请求 | 不可屏蔽 | 之间 | 恢复 |
| 跟踪指令执行 | 同步 | 用户请求 | 可屏蔽 | 之间 | 恢复 |
| 断点 | 同步 | 用户请求 | 可屏蔽 | 之间 | 恢复 |
| 整数运算溢出 | 同步 | 强制发生 | 可屏蔽 | 内部 | 恢复 |
| 浮点运算上溢或下溢 | 同步 | 强制发生 | 可屏蔽 | 内部 | 恢复 |
| 缺页 | 同步 | 强制发生 | 不可屏蔽 | 内部 | 恢复 |
| 未对齐访存 | 同步 | 强制发生 | 可屏蔽 | 内部 | 恢复 |
| 违反存储器保护 | 同步 | 强制发生 | 不可屏蔽 | 内部 | 恢复 |
| 使用未定义指令 | 同步 | 强制发生 | 不可屏蔽 | 内部 | 终止 |
| 硬件故障 | 异步 | 强制发生 | 不可屏蔽 | 内部 | 终止 |
| 电源故障 | 异步 | 强制发生 | 不可屏蔽 | 内部 | 终止 |
停止并重新开始执行
与非流水线实现一样,最难的异常具有两个特征:发生在指令内部,即对应 EX 或 MEM 的执行中途;并且必须允许重新启动。例如,这条 RISC-V 流水线中,取数据引起的虚拟内存缺页要到 MEM 才会发生。发现缺页时,已有几条其他指令正在执行。缺页必须可重启,并需要操作系统等另一执行实体介入。因此,要安全地停止流水线、保存状态,以便在正确状态下重启指令。
通常通过保存重启位置的 PC 实现重启。若重启指令不是分支,就正常取顺序后继并执行;若是分支,则重新判断条件,再从目标或顺序后继取指。异常发生后,流水线控制可按以下步骤安全保存状态:
- 在下一个 IF 强行注入一条陷阱指令。
- 在陷阱真正发生前,禁止故障指令及其后的所有指令写入状态。做法是从异常指令开始,将它及后继指令的流水线锁存器控制置零,但不影响先于它的指令。这样,不能在异常处理前完成的指令就不会改变状态。
- 操作系统异常处理程序得到控制后,立即保存故障指令的 PC,供以后返回使用。
异常处理结束后,用专门指令重新加载 PC 并启动指令流;RISC-V 使用异常返回指令。若流水线可以停止在这样一个边界:故障指令之前的指令全部完成,之后的指令都能从头执行,就称它提供精确异常。
理想情况下,故障指令尚未改变状态;某些异常要正确处理,也确实要求这一点。但对浮点异常等其他异常,某些处理器会在异常得到处理前写回故障指令的结果。此时,即使目的寄存器与某个源寄存器相同,硬件也必须能找回原源操作数。浮点操作可能执行许多周期,其他指令很可能已经改写这些源操作数;下一节将看到,浮点操作常乱序完成。为解决此问题,许多高性能处理器引入两种模式:精确异常模式,以及不保证精确异常的快速/性能模式。精确模式允许的浮点指令重叠较少,因此更慢。
许多系统要求精确异常;另一些系统则因为它能简化操作系统接口而认为它很有价值。最低限度,任何带请求分页或 IEEE 算术陷阱处理程序的处理器,都必须用硬件或适当软件支持提供精确异常。整数流水线较容易实现精确异常,虚拟内存需求也强烈推动了对访存精确异常的支持。实际中,这些原因使设计者与体系结构设计者总为整数流水线提供精确异常。本节介绍 RISC-V 整数流水线的实现,浮点的更复杂问题留到 C.5 节。
RISC-V 中的异常
图 C.27 列出各流水级可能产生的异常。由于同时执行多条指令,同一周期可能发生多个异常。例如:
| 指令/周期 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
ld | IF | ID | EX | MEM | WB | |
add | IF | ID | EX | MEM | WB |
当 ld 位于 MEM、add 位于 EX 时,可能同时发生数据缺页和算术异常。可以先只处理数据缺页,再重启执行。只要软件正确,第一次异常不会再发生,第二次会重新出现,并可单独处理。1
| 流水级 | 可能发生的异常 |
|---|---|
| IF | 取指缺页;未对齐访存;违反存储器保护 |
| ID | 未定义或非法操作码 |
| EX | 算术异常 |
| MEM | 取数据缺页;未对齐访存;违反存储器保护 |
| WB | 无 |
实际情况没有这个例子这么简单。异常可能乱序发生:后一条指令的异常,可能早于前一条指令的异常出现。仍考虑 ld 后跟 add:ld 在 MEM 发现数据缺页,add 在 IF 就可能发现取指缺页。虽然由后面的指令引起,取指缺页却会先出现。
既然要实现精确异常,就必须先处理 ld 的异常。将 ld 所在位置的指令称为 ,将 add 所在位置称为 。不能按异常实际出现的时间立即处理,否则就违反非流水线的顺序。硬件应把每条指令引起的所有异常,记录到与它关联的异常状态向量中,并随指令沿流水线传递。一旦设置异常标志,就关闭所有可能写入数据的控制信号,包括写寄存器和写内存。存储指令可在 MEM 产生异常,因此硬件必须能在它发生异常时阻止写入完成。
当指令进入 WB,或即将离开 MEM 时,检查异常状态向量。若有异常,就按非流水线处理器中应发生的时间顺序处理:先处理最早指令的异常,通常也先处理该指令最早流水级的异常。这样, 的全部异常都会先于 被处理。先前各级为 所做的某些操作可能无效,但由于寄存器与内存写入已禁用,状态不会改变。C.5 节将看到,为浮点操作维持这种精确模型难得多。
下一小节讨论拥有更强大、执行时间更长指令的处理器,其流水线异常处理会遇到什么问题。
指令集带来的复杂性
RISC-V 指令没有多于一个结果,我们的整数流水线也只在执行结束时写入该结果。当指令保证能够完成时,称其已提交(committed)。在这里,全部指令在 MEM 结束、WB 开始时提交,此前不更新状态,所以精确异常较容易实现。
有些处理器的指令在自身及前驱尚未保证完成前,就在执行中途改变状态。例如,原文指出 IA-32 的自增寻址方式会在执行中途更新寄存器。如果此时因异常中止,处理器状态已经改变。虽然知道哪条指令引起异常,若无额外硬件支持,指令只完成一半,异常仍是不精确的,之后很难重启指令流。也可以把状态更新推迟到提交,但这可能困难或昂贵,因为指令自身可能依赖更新后的状态;例如,一条 VAX 指令对同一寄存器多次自增。
因此,具有这类指令的处理器,多数能够撤销指令提交前的一切状态变化。异常发生时,利用这种能力把状态恢复到被中断指令开始之前。下一节将看到,更强的 RISC-V 浮点流水线也会引入类似问题;C.7 节的技术还会显著增加异常处理复杂性。
另一类相关问题,是指令执行过程中更新内存,例如 Intel 或 IBM 360 的字符串复制(见附录 K)。为了能够中断并重启,这些指令规定使用通用寄存器作为工作寄存器。于是,部分完成的指令状态始终保存在寄存器中:异常时保存,处理后恢复,指令就能继续。
某些零散的体系结构状态还可能制造额外冒险,或需要额外的保存/恢复硬件,条件码就是典型例子。许多处理器会在执行指令时隐式设置条件码。这样做有好处:条件判断与实际分支可以分离。但要在设置条件码和分支之间调度指令、填补流水线延迟,就变得困难,因为大多数指令都会设置条件码,不能放在这段间隔中。
此外,采用条件码的处理器必须确定分支条件何时最终有效,即找出分支之前最后一次设置条件码的时刻。大多数隐式设置条件码的处理器,会推迟分支条件判断,直到全部前驱都有机会写条件码。显式设置条件码的体系结构允许调度条件测试与分支之间的间隔,但控制仍须跟踪最后一条设置条件码的指令,以确定分支条件何时已知。实际上,必须把条件码当作一个操作数,检测它与分支之间的 RAW 冒险,正如 RISC-V 对寄存器所做的那样。
另一个棘手领域是多周期操作。设想要把如下 x86 指令序列流水线化:
这些指令本身都不特别长,x86 最长可达 15 字节,但执行周期数差异极大:从一周期到数百周期。数据存储器访问次数也从零到可能数百次不等。数据冒险非常复杂,既发生在指令之间,也发生在指令内部;movsb 的源、目的区域完全可能重叠。
让所有指令执行相同周期数的简单方案不可接受,因为会引入大量冒险与旁路条件,并形成极长的流水线。直接在 x86 指令层面流水线化很难,但人们找到了类似 VAX 所用的巧妙解法:对微指令执行进行流水线处理。微指令是用来组成更复杂指令的一系列简单指令。由于它们很简单,看起来很像 RISC-V,流水线控制容易得多。自 1995 年以来,所有 Intel IA-32 微处理器都采用把 IA-32 指令转换成微操作,再对微操作流水线化的策略。ARM 中某些较复杂指令也采用这种方法。
相比之下,加载—存储处理器的操作简单,工作量相近,更容易流水线化。认识到指令集设计与流水线之间的关系,就能设计出更适合高效流水线的体系结构。下一节将介绍 RISC-V 流水线如何处理执行时间长的浮点指令。
许多年来,人们认为指令集与实现之间关系不大,设计指令集时没有把实现问题作为重点。20 世纪 80 年代,人们逐渐认识到,指令集的复杂性会同时增加流水线实现难度并降低效率。90 年代,各家公司都转向更简单的指令集,以降低激进实现的复杂度。
译注: 本节沿用可产生整数算术异常的教学流水线例子。不能据此认为标准 RISC-V 的普通
add会因整数溢出自动陷阱。 ↩︎