跳到内容

第 3 卷 / 第 15 章 / 第 1 课

15.1 流水线与数据冒险 ​

把一条指令拆成多个阶段,并让不同指令同时占据不同阶段,就能提高吞吐。流水线没有让某一条指令凭空更快;它缩短的是连续指令完成之间的间隔。

经典五级 RISC 流水线适合建立模型,现代 CPU 则可能拥有更深、更宽且动态调度的前后端。先把简单模型学准,再进入分支预测与乱序执行。

1. 延迟与吞吐不是一回事 ​

假设洗衣需要洗、烘、叠三步,每步 30 分钟,三个工位可同时处理不同篮子:

  • 一篮衣服从开始到完成,延迟仍是 90 分钟;
  • 流水线填满后,每 30 分钟可以完成一篮,吞吐提高。

指令流水线同理。经典五级划分是:

阶段名称主要工作
IFInstruction Fetch根据 PC 取指令
IDInstruction Decode译码并读取寄存器
EXExecuteALU 运算或地址生成
MEMMemory Access访问数据存储层次
WBWrite Back写回寄存器结果

理想时间线:

text
cycle        1    2    3    4    5    6    7
instruction A IF   ID   EX   MEM  WB
instruction B      IF   ID   EX   MEM  WB
instruction C           IF   ID   EX   MEM  WB

A 仍经过五级,B 却可以在 A 完成前开始。稳定状态下,理想的单发射流水线每周期完成一条指令,IPC 接近 1。

“分成五级就快五倍”不成立。实际加速受以下因素限制:

  • 最慢一级决定时钟周期;
  • 级间寄存器本身有延迟与功耗;
  • 流水线需要填充和排空;
  • 依赖、分支和资源冲突会插入空泡;
  • cache miss 可能远长于一个流水级。

2. 三类流水线冒险 ​

结构冒险 ​

两条在途指令同一周期需要同一个硬件资源。例如统一单端口存储器既要取下一条指令,又要为当前 load 读数据。可增加端口、分开指令与数据存储、复制资源,或者停顿;并行硬件也要付面积、功耗与布线成本。

数据冒险 ​

指令之间存在寄存器或内存依赖:

RAW(Read After Write) 是真依赖:

asm
add x5, x1, x2
sub x6, x5, x3   # 必须读取 add 产生的新 x5

WAR(Write After Read) 是反依赖:

asm
read x5
write x5

WAW(Write After Write) 是输出依赖:

asm
write x5
write x5

这里 read/write 只是依赖示意,不是 RISC-V 指令。假设寄存器都在 ID 读、WB 按序写的简单五级流水线,主要面对 RAW。WAR/WAW 在允许指令乱序执行或完成时才会阻碍调度,现代 CPU 用 register renaming 消除它们。内存依赖更难,因为两个地址可能直到执行阶段才知道是否相同。

控制冒险 ​

遇到条件分支或间接跳转时,取指单元尚不知道下一条 PC。若等条件完全解析再取指,会留下空闲周期;若先猜,猜错后就要丢弃错误路径上尚未提交的工作,并从正确地址重新开始。

预测越早、前端越深、发射越宽,错误路径可能占用的资源就越多。下一节会单独展开。

3. 转发让结果提前抵达 ​

在经典五级流水线里,ALU 结果在 EX 末尾已经产生,却可能到 WB 才写入寄存器堆。若下一条指令等到 WB 后再读,会浪费周期。

forwarding/bypassing 把中间流水寄存器中的结果直接送到后续 ALU 输入:

text
add: IF ─ ID ─ EX ─ MEM ─ WB
                 ╲
sub:      IF ─ ID ─ EX ─ MEM ─ WB
                   ↑
             forwarded value

控制逻辑比较后续指令源寄存器与前面在途指令目的寄存器,再选择寄存器堆值、EX/MEM 结果或 MEM/WB 结果。

转发网络是微架构细节,反汇编里看不到“这一条走了旁路”。汇编只能暴露数据依赖,具体 CPU 决定是否以及从哪一级转发。

4. load-use 为什么仍可能停顿 ​

考虑:

asm
lw  x5, 0(x10)
add x6, x5, x7

在经典模型中,lw 的数据到 MEM 末尾才产生,紧随其后的 add 却在同一周期的 EX 开始就需要它。普通 EX-to-EX 转发来不及,hazard unit 会让依赖指令停一个周期:

text
lw : IF ─ ID ─ EX ─ MEM ─ WB
add:      IF ─ ID ─ stall ─ EX ─ MEM ─ WB

“一定停一周期”只属于这套具体流水级与 cache 命中假设。不同微架构可能调度、重放或以不同延迟提供 load 结果;cache miss 更会把等待拉长很多周期。

编译器可以在中间安排一条独立指令,现代乱序 CPU 也可以执行其他已就绪工作来隐藏部分延迟。没有独立工作时,真依赖链仍然决定下限。

5. 依赖链限制指令级并行 ​

下面两个函数使用无符号整数,结果在模 2^64 意义下相同;本例数据未超出范围,因此也等于数学总和。浮点加法不满足同样的重分组等价性。第一版只有一个累加器,每次加法依赖上一次;第二版使用四个独立累加器,最后再合并:

c
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

static uint64_t sum_one(const uint32_t *values, size_t length) {
    uint64_t sum = 0;
    for (size_t i = 0; i < length; ++i) {
        sum += values[i];
    }
    return sum;
}

static uint64_t sum_four(const uint32_t *values, size_t length) {
    uint64_t a = 0;
    uint64_t b = 0;
    uint64_t c = 0;
    uint64_t d = 0;
    size_t i = 0;

    size_t bulk_end = length - length % 4;
    for (; i < bulk_end; i += 4) {
        a += values[i];
        b += values[i + 1];
        c += values[i + 2];
        d += values[i + 3];
    }
    for (; i < length; ++i) {
        a += values[i];
    }
    return a + b + c + d;
}

int main(void) {
    enum { COUNT = 100000 };
    uint32_t *values = malloc(sizeof(*values) * COUNT);
    if (values == NULL) {
        return EXIT_FAILURE;
    }

    for (size_t i = 0; i < COUNT; ++i) {
        values[i] = (uint32_t)(i % 97);
    }

    uint64_t first = sum_one(values, COUNT);
    uint64_t second = sum_four(values, COUNT);
    printf("%" PRIu64 " %" PRIu64 "\n", first, second);

    free(values);
    return first == second ? EXIT_SUCCESS : EXIT_FAILURE;
}
bash
cc -std=c17 -O2 -Wall -Wextra accumulators.c
./a.out

多个累加器提供了独立加法,给超标量或乱序 CPU 更多可并行工作。不过不能直接把运行时间差归因于流水线:编译器可能自动展开、向量化或把两个函数优化成不同指令。分析时应同时查看汇编,并用适合目标 CPU 的性能计数器测量。

6. 编译器、ISA 与微架构的边界 ​

一段 C 循环经过多层转换:

text
C 源码
  ↓ compiler optimization
ISA instructions
  ↓ decode / rename / schedule
micro-operations and execution units

编译器知道目标 ISA、指令延迟模型和静态依赖,可以:

  • 删除无用工作;
  • 展开循环;
  • 选择 branch 或条件移动;
  • 自动向量化;
  • 调整独立指令顺序;
  • 通过别名分析证明两个内存访问不冲突。

CPU 在运行时看到真实 cache miss、分支历史和操作数就绪状态,可以动态调度。两者合作,但不能互相替代。

C 的 volatile 不是“禁止 CPU 乱序”的通用屏障。它主要约束抽象机中对 volatile object 的访问,常用于内存映射 I/O 等特定接口;线程同步应使用原子操作、mutex 和语言内存模型。Java volatile 则有语言规范定义的跨线程可见性与顺序语义,两者不能按名字类比。

7. cache miss 会把短流水线问题放大 ​

ALU 操作可能很快,内存层次延迟却相差很大。load miss 后,CPU 可能:

  • 在 cache 层次继续查找;
  • 生成并跟踪未完成内存请求;
  • 执行与该 load 无关的后续指令;
  • 在资源耗尽或遇到依赖时停住;
  • 最终从更远层次取得数据并唤醒依赖指令。

memory-level parallelism 允许多个独立 miss 同时在途。链表遍历的下一个地址依赖当前节点内容,通常难以产生这种并行;数组顺序访问则更容易被预取并发起多个请求。

因此“优化流水线”常常落到数据布局:连续访问、减少指针追逐、压缩工作集和避免伪共享。

8. 精确异常要求看起来按顺序 ​

即使简单流水线让多条指令同时在途,架构状态也要呈现可理解的边界。若一条 load 发生页故障:

  • 它之前的指令应表现为已经完成;
  • 对本例的单次 load,它及之后的指令不应留下不可撤销的目的寄存器或普通内存结果;
  • 异常处理结束后可以从正确位置继续或终止。

ISA 定义的异常记录、页表 accessed 位或可重启复合指令可能有各自的部分进度规则,不能概括成“故障时什么都没改变”。顺序流水线可以通过控制各级写入维护规定的边界。乱序 CPU 则需要更明确的 retirement/commit 机制,下一节会介绍 reorder buffer 如何维护精确异常。

9. 分析一道依赖题 ​

给定:

asm
add x5, x1, x2
lw  x6, 0(x5)
xor x7, x6, x3
add x8, x4, x9

可以按顺序标注:

  1. lw 计算地址依赖 add 的 x5,是 RAW;ALU 转发可能解决;
  2. xor 依赖 lw 的 x6,是 load-use RAW,在经典五级模型中要停顿;
  3. 最后一条 add 与前三条寄存器独立,可在更先进的调度器里越过等待执行;
  4. 仅凭汇编不能断言真实 CPU 停了几周期,还要知道微架构与 cache 状态。

正确的分析语言是“依赖允许什么”,不是为所有处理器背一张固定周期表。

10. 小结 ​

流水线通过重叠不同指令的阶段提高吞吐:

  • 五级 RISC 模型把取指、译码、执行、访存和写回分开;
  • 结构 hazard 来自资源争用,数据 hazard 来自依赖,控制 hazard 来自下一条 PC 的不确定性;
  • forwarding 缩短结果传递路径,但不能消除所有 load-use 等待;
  • 多个独立累加器能暴露更多指令级并行;
  • cache miss、内存依赖和数据布局常比理想五级时间线更重要;
  • volatile 不是线程同步或硬件排序的万能开关。

下一节进入分支预测、超标量与乱序执行,看现代 CPU 如何在保持架构结果有序的同时,让内部执行大幅越序。

对照实现与编译器文档 ​

Built with VitePress | Software Systems Atlas