Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.4 处理器体系结构:从 Y86-64 到乱序执行

本章回答

一条指令从取指到写回要经过哪些阶段?流水线为什么会让「下一条指令」读到旧值?转发、暂停、分支预测各自解决什么问题?现代 CPU 为什么要乱序执行,又是如何保证结果和顺序执行一样?

读完你会

读懂 Y86-64 的指令编码与 HCL;画出五级流水线并判断数据冒险;用 CPI、IPC、分支缺失率估算性能;解释寄存器重命名与 ROB 的作用;用 perf stat 判断程序是前端、后端还是分支受限。

第 1.3 章把 C 翻译成指令,这一章把指令放进硬件。视角从「程序怎么写」切换到「CPU 怎么执行」:一条 addq 在哪个周期读寄存器、哪个周期算、哪个周期写回;当下一条指令依赖它的结果时,硬件是等待、绕过,还是猜?

教材通常用 Y86-64 讲这条路径。它是一个教学 ISA:指令少、编码规整、没有延迟槽和复杂寻址,适合把数据通路拆开看。Y86-64 不追求性能,也不能直接跑 Linux;它的价值是让你在没有历史包袱的模型上理解「流水线」「冒险」「控制」这些概念,再把它们映射到 x86-64 与 ARM64。

先给结论:现代高性能 CPU 的顺序执行模型是假象。指令在硬件里乱序、并发、推测地执行,但提交(commit)按程序顺序进行,所以软件看到的寄存器与内存状态仍像一条一条执行的。理解这个假象如何维持,是理解 Spectre、内存屏障、volatile、锁的前提。

1.4.1 ISA 与微架构:Y86-64 的定位

定义 1.4.1 · 指令集体系结构(ISA)

ISA 是软件与硬件的契约:有哪些寄存器、指令如何编码、每条指令对可见状态(寄存器、内存、标志位、PC)做什么、异常如何报告。微架构(microarchitecture)是实现 ISA 的具体电路:流水线级数、发射宽度、缓存大小、预测器算法。同一 ISA 可以有多种微架构(如 x86-64 的 Skylake 与 Zen),同一微架构也可以实现多种 ISA。

这个区分解释了一个常见困惑:为什么 Intel 和 AMD 的 CPU 都能跑同样的二进制?因为它们实现同一个 ISA(x86-64),但微架构完全不同。为什么 ARM 程序不能直接跑在 x86 上?因为 ISA 不同,指令编码、寄存器、内存模型都不同,需要翻译层(模拟器、二进制翻译)或重新编译。

Y86-64 是 CSAPP 设计的教学 ISA, borrowed x86-64 的寄存器名和部分指令形式,但做了大幅简化:

  • 只有 15 条指令(按操作码计),没有浮点、没有 SIMD、没有特权指令;
  • 8 个通用寄存器(%rax、%rcx、%rdx、%rbx、%rsp、%rbp、%rsi、%rdi),没有 %r8–%r15;
  • 指令长度 1–10 字节,操作码 1 字节,寄存器字节 1 字节,立即数/地址 8 字节小端;
  • 只有 4 个条件码(ZF、SF、OF),没有 CF;
  • 内存访问只有 mrmovq/rmmovq,没有复杂的寻址模式(只有 D(Rb));
  • 没有异常/中断的完整机制,只有 4 种状态码(AOK、HLT、ADR、INS)。

Y86-64 不能运行真实程序,它没有系统调用、没有页表、没有设备。它的作用是让你能在纸上或模拟器里跟踪每条指令的五个阶段。读完这一章,你应当能把 Y86-64 的 addq 与 x86-64 的 addq、ARM64 的 add 对应起来,并知道后两者多了哪些复杂性。

1.4.2 Y86-64 指令集与编码

Y86-64 的指令编码规则:第一个字节的高 4 位是操作码(icode),低 4 位是功能码(ifun);第二个字节的高 4 位是源寄存器 rA,低 4 位是目的寄存器 rB;后面跟 8 字节立即数或地址(小端)。寄存器编号:

编号01234567F
寄存器%rax%rcx%rdx%rbx%rsp%rbp%rsi%rdi无

主要指令:

指令icode编码示例字节数效果
halt0001停止
nop1101空操作
rrmovq rA,rB220 032rB = rA
irmovq V,rB330 F0 01 00 00 00 00 00 00 0010rB = V
rmmovq rA,D(rB)440 01 08 00 ...10M[rB+D] = rA
mrmovq D(rB),rA550 01 08 00 ...10rA = M[rB+D]
addq rA,rB660 032rB += rA,设标志
subq rA,rB6/161 032rB -= rA,设标志
andq/xorq6/2、6/362 03、63 032位运算,设标志
jXX dest770 <8B>、73 <8B>9条件跳转
call dest880 <8B>9压返回地址并跳转
ret9901弹出返回地址
pushq rAAA0 0F2rsp -= 8; M[rsp] = rA
popq rABB0 0F2rA = M[rsp]; rsp += 8

irmovq $1, %rax 的编码是 30 F0 01 00 00 00 00 00 00 00:0x30 表示 irmovq,F0 表示源寄存器为无(F)、目的 %rax(0),后 8 字节是 1 的小端。Y86-64 的立即数总是 8 字节,不像 x86-64 有 1/2/4 字节的紧凑形式;这是教学 ISA 为简化译码付出的空间代价。

指令语义用汇编写出来:

# long sum(long *a, long n)
# a in %rdi, n in %rsi, result in %rax
sum:
    xorq   %rax, %rax          # s = 0
    testq  %rsi, %rsi          # n ? 0
    jle    done
loop:
    mrmovq (%rdi), %rdx        # rdx = *a
    addq   %rdx, %rax          # s += rdx
    irmovq $8, %rcx
    addq   %rcx, %rdi          # a += 8
    irmovq $1, %rcx
    subq   %rcx, %rsi          # n -= 1
    jne    loop
done:
    ret

注意 Y86-64 没有 testq 指令(这里是为了可读性借用了 x86 助记符),实际要用 andq %rsi,%rsi 或 subq $0 来设标志;也没有 leaq,地址计算只能靠 addq/irmovq。这个「缺指令」的体验很重要:你会意识到高级语言里的每个操作,最终都要落到 ISA 提供的有限指令上。

把这段循环按五级流水线展开,注意 mrmovq 与 addq 之间的 load-use 冒险:

周期      C1   C2   C3   C4   C5   C6   C7   C8
mrmovq    F    D    E    M    W
addq           F    D    D*   E    M    W
irmovq              F    F*   D    E    M    W
                          ↑
                    暂停一周期:I2 在 D 等 load 数据
                    F* 表示取指被冻结,重复取同一条

I2 在 C4 仍停在 D,因为 mrmovq 的数据要到 C4 末(M 阶段)才可用;C5 通过 M→E 转发拿到 %rdx。这一周期的气泡让 3 条指令用了 8 个周期,而不是理想的 7 个。循环里每次迭代都有一条 load,如果每次都触发暂停,CPI 会明显高于 1。编译器可以把 addq 后面不依赖 load 的指令移到前面,填掉这个气泡。

关于 Y86-64 的三个误解
  1. 「Y86-64 是 x86-64 的子集」——不是。它借用了寄存器名和部分指令形式,但编码、指令集、异常模型都不同;x86-64 的二进制不能在 Y86-64 上运行。
  2. 「Y86-64 的 8 字节立即数说明它落后」——这是教学简化。真实 x86-64 有变长指令(1–15 字节),译码复杂度高得多;Y86-64 用固定 8 字节让译码器简单到可以在纸上画。
  3. 「学 Y86-64 没用,反正不生产」——它训练的「取指—译码—执行—访存—写回」模型适用于所有 CPU。换成 ARM64 只是阶段名和寄存器名不同。

1.4.3 HCL 与顺序实现

硬件控制语言(HCL, Hardware Control Language)用布尔表达式描述「某个控制信号在什么条件下为真」。它不是 Verilog,不描述时序,只描述组合逻辑。Y86-64 的顺序实现(SEQ)把每条指令拆成六个阶段,每个阶段用一个 HCL 块描述。

六个阶段:

阶段做什么关键信号
取指(Fetch)从 PC 读指令字节,解析 icode/ifun/rA/rB/valCimem_error、instr_valid
译码(Decode)读寄存器堆,得到 valA/valBsrcA、srcB、dstE、dstM
执行(Execute)ALU 计算,设条件码aluA、aluB、alufun、set_cc
访存(Memory)读/写数据内存mem_addr、mem_data、mem_read、mem_write
写回(Writeback)把结果写回寄存器堆dstE、dstM
更新 PC计算下一条指令地址new_pc

控制信号用 HCL 写出来,形式是「条件 : 表达式;」:

# 读哪个寄存器作为 ALU 的 A 输入
word aluA = [
    icode in { IRRMOVQ, IOPQ } : valA;
    icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ } : valC;
    icode in { ICALL, IPUSHQ } : -8;
    icode in { IRET, IPOPQ } : 8;
];

这段的意思是:rrmovq/OPq 用 valA;irmovq/rmmovq/mrmovq 用立即数 valC;call/pushq 要让 rsp 减 8;ret/popq 要让 rsp 加 8。HCL 的 [ ] 是优先级选择,第一个匹配的条件生效。

顺序实现的时序是:一个时钟周期内完成六个阶段,下一个周期更新 PC。CPI 接近 1(忽略访存延迟),但时钟周期必须容纳最慢的那条路径(取指 + 译码 + ALU + 访存 + 写回),所以频率上不去。流水线把这条长路径切成五段,每段一个周期,吞吐率提高,但引入了冒险。

为什么顺序实现的 CPI 是 1,性能却不好

CPI = 1 只说明「每条指令一个周期」,没说明「周期有多长」。顺序实现的一个周期要串行完成取指、译码、执行、访存、写回,时钟周期由最慢的组合路径决定,可能是流水线的 5 倍。性能 = 指令数 × CPI × 周期时间;流水线把周期时间降到 1/5,即使 CPI 略大于 1,整体仍快得多。

1.4.4 五级流水线

把六个阶段合并成五个流水级(写回与更新 PC 合并到 W 或单独处理),每级之间放流水寄存器(pipeline register),保存上一级的中间结果:

指令 i    ┌────┐  ┌────┐  ┌────┐  ┌────┐  ┌────┐
          │ F  │→│ D  │→│ E  │→│ M  │→│ W  │
          └────┘  └────┘  └────┘  └────┘  └────┘
           取指     译码     执行     访存     写回
           PC      寄存器   ALU     数据内存  寄存器堆
​
时间 →    C1   C2   C3   C4   C5   C6   C7
指令 i    F    D    E    M    W
指令 i+1       F    D    E    M    W
指令 i+2            F    D    E    M    W
指令 i+3                 F    D    E    M    W
指令 i+4                      F    D    E    M    W

理想情况下,每个周期完成一条指令(吞吐率 1/cycle),单条指令的延迟是 5 个周期。流水线的收益来自「同时执行 5 条指令的不同阶段」,不是让单条指令变快。

流水线寄存器保存什么:

寄存器保存的内容
F→D预测的下一条 PC、当前指令的 icode/ifun/rA/rB/valC/valP
D→EvalA、valB、dstE、dstM、srcA、srcB、valC
E→MALU 结果 valE、条件码、dstE、dstM、valA、valB
M→W内存读出的 valM、ALU 结果 valE、dstE、dstM

每个寄存器在每个时钟上升沿更新;组合逻辑在周期内传播。控制信号(aluA、dstE 等)由当前级的 icode 译码得到,随流水寄存器一起传递。

流水线带来的三类冒险:

冒险原因典型例子解决
数据冒险后一条指令需要前一条还没写回的结果addq %rax,%rbx 后 mrmovq (%rbx),%rcx转发、暂停
控制冒险取指时不知道下一条 PC条件跳转、ret预测、延迟、冲刷
结构冒险两条指令同时要用一个硬件资源同时访存与取指分离 I-cache/D-cache、增加端口

1.4.5 数据冒险:转发与暂停

考虑两条相邻指令:

addq %rax, %rbx      # I1: rbx = rax + rbx,结果在 E 阶段末产生
mrmovq (%rbx), %rcx  # I2: 需要 rbx 作为地址

I2 在 D 阶段读寄存器堆时,I1 还在 E 阶段,结果尚未写回。如果不处理,I2 读到的是旧的 %rbx。

转发(forwarding/bypassing) 把 I1 在 E 阶段末的 ALU 结果直接送到 I2 的 E 阶段输入,不等写回。数据通路增加多路选择器:

E 阶段 ALU 输入 A 的选择:
  1. 来自 D→E 寄存器的 valA(正常)
  2. 来自 E→M 寄存器的 valE(上一条指令的 ALU 结果)
  3. 来自 M→W 寄存器的 valE 或 valM(上上条指令的结果)
优先级:E→M 高于 M→W(越新越优先)

load-use 冒险 是转发解决不了的:mrmovq (%rax),%rbx 后紧跟 addq %rbx,%rcx。加载的数据在 M 阶段末才从内存出来,而 I2 的 E 阶段在下一个周期就要用,来不及。硬件必须暂停(stall) 一个周期:冻结 PC 和 F/D 寄存器,往 E 阶段插入一个 nop(气泡),等数据从 M→W 转发过来。

周期        C1   C2   C3   C4   C5   C6
mrmovq      F    D    E    M    W
addq             F    D    E*   E    M    W
                      ↑
                 暂停一周期,插入气泡;E* 等待
​
转发路径:M 阶段末的 valM → W 阶段 → 下一条的 E 阶段

暂停的代价是 CPI 上升。若程序中 load 指令占 20%,其中 30% 紧跟使用其结果的指令,则每条 load 平均多 0.3 周期,整体 CPI 增加 0.2×0.3=0.060.2 \times 0.3 = 0.06。看起来不大,但在 tight loop 里可能成为瓶颈;编译器会通过指令调度(把无关指令填进 load 延迟槽)来减少暂停。

转发也有优先级:如果 I1 和 I2 都写同一个寄存器,I3 应该拿 I2 的结果(更新的),而不是 I1 的。硬件用「目的寄存器编号匹配 + 非零」做旁路比较,优先级从新到旧。

定义 1.4.2 · 数据冒险(data hazard)

流水线中两条指令存在数据依赖,且后一条在前一条的结果写回之前就要读它,导致读到旧值。按依赖类型分 RAW(读后写,真正的冒险)、WAR(写后读,乱序执行时出现)、WAW(写后写,乱序执行时出现)。顺序五级流水线只会有 RAW;乱序执行三种都有,靠寄存器重命名消除 WAR/WAW。

1.4.6 控制冒险与分支预测

取指阶段要知道下一条 PC。对顺序指令,valP = PC + 指令长度;对 jXX、call、ret,下一条 PC 取决于条件、寄存器或内存。流水线不能在取指时就算出这些,于是先预测一个方向,错了再冲刷(flush)。

最简单的预测是「总是跳转」或「总是不跳转」。现代 CPU 用两位饱和计数器(2-bit saturating counter):每个分支地址维护一个 2 位状态,00/01 倾向不跳,10/11 倾向跳;连续两次同向才改变预测。它对循环特别有效:循环体重复执行时,分支几乎总是同一方向。

两位饱和计数器:
  00 ──taken──> 01 ──taken──> 11 ──taken──> 11
   ↑              │              │
   └──not taken───┴──not taken──┘
预测:高位为 1 则跳转(10/11),为 0 则不跳(00/01)

更复杂的预测器用分支历史寄存器(BHR)、Tournament(局部/全局结合)、TAGE(长历史)等。预测器越准,流水线越少断。预测失败的代价是多少?假设流水线 15 级,分支在 E 阶段解析,预测失败要冲刷 12–15 条指令,代价 12–15 周期。

用 CPI 估算收益:设分支指令占 20%,预测准确率 90%,失败代价 15 周期,基础 CPI 为 1:

CPI=1+0.20×(1−0.90)×15=1+0.30=1.30. \text{CPI} = 1 + 0.20 \times (1-0.90) \times 15 = 1 + 0.30 = 1.30 .

若预测准确率提高到 99%,CPI 降到 1+0.20×0.01×15=1.031 + 0.20 \times 0.01 \times 15 = 1.03。预测准确率从 90% 到 99%,性能提升约 26%,这就是现代 CPU 在预测器上投入大量晶体管的原因。

分支是否可预测,对真实代码的影响可以实测。下面的程序对同一组数据做两种求和:一种用 if,一种用无分支的掩码。数据前半段全小于 128、后半段全大于等于 128 时,分支高度可预测;用 xorshift 打乱后,分支变得随机:

#include <stdio.h>
#include <stdint.h>
#include <time.h>

#define N (1u << 24)
static uint8_t data[N];
static volatile uint64_t sink;

static uint64_t now_ns(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}

static uint32_t xs_state = 123456789u;
static uint32_t xs(void)
{
    uint32_t x = xs_state;
    x ^= x << 13; x ^= x >> 17; x ^= x << 5;
    return xs_state = x;
}

int main(void)
{
    for (uint32_t i = 0; i < N; i++)
        data[i] = (uint8_t)(xs() >> 24);      /* 0..255 随机 */

    uint64_t t0 = now_ns();
    uint64_t s1 = 0;
    for (uint32_t i = 0; i < N; i++)
        if (data[i] < 128) s1 += data[i];     /* 难预测的分支 */
    uint64_t t1 = now_ns();

    uint64_t s2 = 0;
    for (uint32_t i = 0; i < N; i++) {
        uint8_t v = data[i];
        s2 += (v < 128) ? v : 0;              /* 可能编译成 cmov */
    }
    uint64_t t2 = now_ns();
    sink = s1 + s2;

    printf("branchy: %.2f ns/iter, branchless: %.2f ns/iter\n",
           (double)(t1 - t0) / N, (double)(t2 - t1) / N);
    printf("sink=%llu\n", (unsigned long long)sink);
    return 0;
}

在 x86-64 上,branchy 版本遇到随机分支时会有大量 branch-misses,branchless 版本则稳定在较低 CPI。把数据改成「前一半 0、后一半 255」,branchy 会突然变快——分支变得可预测。同一个分支,数据分布不同,性能可能差一个数量级,这是基准测试必须用真实数据的原因。

ret 是另一种控制冒险:返回地址在运行时才知道。CPU 用返回地址栈(return address stack, RAS)预测:call 时把 valP 压入 RAS,ret 时弹出。递归深度超过 RAS 容量时预测失败,但通常 RAS 有 16–32 项,足够覆盖大多数调用链。

关于分支预测的四个误解
  1. 「分支预测是猜,所以不可靠」——现代预测器在多数整数/服务负载上准确率超过 95%,循环分支接近 100%。它是性能的关键,不是可有可无的优化。
  2. 「把分支改成 cmov 一定更快」——cmov 把控制依赖变成数据依赖,若两个分支的计算都很贵,或源操作数可能 fault,反而更慢。要看具体代码。
  3. 「分支预测失败只浪费一个周期」——现代深流水线失败代价 12–20 周期,且会污染缓存与前端。perf stat 的 branch-misses 能直接看到。
  4. 「预测器可以被软件完全控制」——不能。你可以用 __builtin_expect、likely/unlikely 影响布局,但最终预测由硬件历史决定;安全场景还要考虑 Spectre 的推测执行副作用。

1.4.7 现代乱序执行:重命名、ROB 与超标量

五级顺序流水线每周期最多 1 条指令,遇到长延迟操作(缓存缺失、除法)就整条流水线停顿。现代 CPU 用乱序执行(out-of-order, OoO) 把「执行顺序」与「提交顺序」解耦:

前端                          后端
┌────────┐  ┌────────┐  ┌──────────┐  ┌──────────────┐  ┌────────┐
│ 取指   │→│ 译码   │→│ 重命名   │→│ 发射/调度    │→│ 执行   │
│ I-cache│  │ 微操作 │  │ 寄存器   │  │ 保留站       │  │ ALU/LS │
└────────┘  └────────┘  │ 映射表   │  │ 乱序选择     │  └────┬───┘
                        └──────────┘  └──────────────┘       │
                                                             ↓
                        ┌──────────┐  ┌──────────────┐  ┌────────┐
                        │ 提交     │←│ ROB          │←│ 写回   │
                        │ 按序     │  │ 重排序缓冲区 │  │ 广播   │
                        └──────────┘  └──────────────┘  └────────┘

关键部件:

  • 寄存器重命名(register renaming):把架构寄存器(%rax 等 16 个)映射到物理寄存器(可能有 180–300 个)。每条写指令分配一个新的物理寄存器,消除 WAW/WAR 假依赖,只保留真正的 RAW。
  • 保留站(reservation station)/发射队列:指令译码后进入队列,等待操作数就绪;一旦就绪就发射到执行单元,不按程序顺序。
  • 重排序缓冲区(ROB, reorder buffer):按程序顺序记录所有未提交的指令。执行可以乱序,但提交必须按序;只有提交时,寄存器堆和内存才对软件可见。
  • 超标量(superscalar):每周期取指/译码/发射多条(4–8 条)。宽度受限于译码器、寄存器端口、执行单元数量。
  • 推测执行(speculative execution):分支预测后继续执行,结果先放 ROB;预测失败就丢弃。Spectre 利用的正是「推测执行留下的缓存副作用」。

乱序执行让 CPI 可以小于 1(IPC > 1)。例如 4 宽 CPU,理想 IPC 4;实际受依赖链、缓存缺失、分支影响,常见 IPC 1–3。perf stat 的 instructions per cycle 就是 IPC。

一个具体例子:三个相互独立的加法,和一个依赖链的三个加法。乱序 CPU 可以同时发射前三个(若端口够),但依赖链必须串行:

独立:  a = b + c;  d = e + f;  g = h + i;     // 3 条可并行,IPC 接近 3
依赖:  a = b + c;  d = a + e;  g = d + f;     // 只能串行,IPC 接近 1

编译器可以通过循环展开、多累加器把依赖链拆开(1.5);硬件可以通过重命名消除假依赖,但真正的数据依赖无法消除。

用一张调度表看乱序的收益。程序是「load → 依赖 add → 两条独立 add」:

指令顺序执行的开始乱序执行的开始说明
load r1,[r0]11长延迟,4 周期
add r2,r1,#155必须等 load
add r3,r4,#562与 load 无关,提前发射
add r5,r6,#772同上

顺序执行在 load 的 4 个周期里什么都不做;乱序执行把两条独立加法填进去,总时间从 7 周期降到 5 周期。如果独立指令足够多,乱序可以把长延迟操作「藏」在其他工作后面——这就是为什么 OoO 对缓存缺失、除法、分支失败特别有价值。

内存乱序与内存屏障

OoO CPU 还会乱序执行 load/store,只要不违反单核的地址依赖。多核之间需要内存屏障(mfence、lock 前缀、ARM 的 dmb)来约束可见顺序。C11 的 atomic_thread_fence 和 memory_order 就是把这些硬件语义暴露给程序员。第 1.12 章讲并发时会展开。

超线程(SMT)与多核

一个物理核可以同时维护两份架构状态(寄存器、PC),共享执行单元、缓存、分支预测器,这叫同时多线程(simultaneous multithreading, SMT),Intel 称 Hyper-Threading。对访存密集型负载,SMT 能用另一个线程填满执行单元的空隙;对纯计算、缓存友好的负载,两个线程会争抢执行端口,单线程性能可能下降。/sys/devices/system/cpu/cpu*/topology/thread_siblings_list 可以看到哪些逻辑 CPU 共享物理核;lscpu -e 更直观。

1.4.8 ARM Cortex-A 对照与 perf 观测

ARM Cortex-A 系列(如 A76、A78、X1)是典型的高性能 OoO 核,流水线深度 8–13 级,译码宽度 4–6,支持乱序执行与 speculation。与 x86-64 的差别主要在 ISA 与前端:

维度x86-64(Skylake 级)ARM Cortex-A76 级
指令长度变长 1–15 字节定长 4 字节
译码复杂,需预译码规整,易并行译码
流水线深度14–19 级8–13 级
发射宽度4–64
分支预测TAGE 类,很准类似,稍简单
内存模型TSO(较强)弱内存序,需屏障
寄存器16 GPR31 GPR

ARM64 的弱内存模型意味着:两个核看到的写顺序可能不同,必须用 dmb ish、ldar/stlr 或 C11 原子操作来同步。x86-64 的 TSO 较强,普通 load/store 不会重排(但 store 之间仍可能缓冲),所以很多 x86 程序「碰巧能跑」,移植到 ARM 才暴露竞态。

流水线深度是频率与失败代价的权衡:级数越多,每级组合逻辑越短,频率越高;但分支失败要冲刷的指令也越多,且每级寄存器开销(setup/hold、时钟偏斜)占比上升。2000 年代 Intel Pentium 4 曾把流水线推到 31 级,结果分支失败代价过高、功耗失控;现代高性能核回到 14–19 级,靠更宽的发射和更好的预测提升 IPC,而不是一味加深流水线。这也是「主频竞赛」在 2005 年前后结束的技术原因之一。

用 perf stat 判断瓶颈:

perf stat -e cycles,instructions,branches,branch-misses,\
cache-references,cache-misses,stalled-cycles-frontend ./prog

读法:

  • IPC = instructions / cycles。小于 1 说明流水线经常停;大于 1 说明超标量生效。
  • branch-misses / branches 高(>5%)说明分支预测是瓶颈,检查热循环里的不可预测分支。
  • cache-misses / cache-references 高说明访存是瓶颈,进入 1.6。
  • stalled-cycles-frontend 高说明取指/译码供不上,可能是 I-cache 缺失或指令过长。

一次真实的 perf stat 输出(数值因机器而异):

$ perf stat -e cycles,instructions,branches,branch-misses ./prog
   1,234,567,890      cycles
     987,654,321      instructions              # 0.80  insn per cycle
      12,345,678      branches                  # 10.00 M/sec
         345,678      branch-misses             # 2.80% of all branches

IPC=0.80 说明每周期不到一条指令,流水线有闲置;branch-misses=2.8% 不算高,不是首要矛盾;若 cycles 远大于 instructions 且缓存缺失率低,接下来该看 stalled-cycles-backend 与具体端口的利用率(perf stat -e cycles,uops_dispatched.port_* 或 perf stat -dd)。

一个依赖链基准,用来观察 IPC 的变化:

#include <stdio.h>
#include <stdint.h>
#include <time.h>

#define N (1u << 26)
static volatile uint64_t sink;

static uint64_t now_ns(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}

int main(void)
{
    uint64_t a = 0, b = 0, c = 0, d = 0;
    uint64_t t0 = now_ns();

    for (uint32_t i = 0; i < N; i++) {
        a += i;            /* 一条依赖链:每次迭代依赖上一次 a */
        b += i + 1;
        c += i + 2;
        d += i + 3;
    }
    uint64_t t1 = now_ns();
    sink = a + b + c + d;

    printf("4 chains: %.2f ns/iter, sink=%llu\n",
           (double)(t1 - t0) / N, (unsigned long long)sink);
    return 0;
}

四条独立链可以让 CPU 同时推进;如果把四条合成一条 a += i; a += i+1; ...,IPC 会明显下降,因为每步都依赖上一步。这个实验不需要特殊工具,clock_gettime 就够;用 perf stat 看两种写法的 IPC 差异更直观。

用 perf 判断流水线瓶颈的四步
  1. perf stat -d ./prog 看 IPC、L1/LLC 缺失、分支缺失、前端停顿;
  2. IPC 低且 branch-misses 高:用 perf record -e branch-misses 找具体分支;
  3. IPC 低且 LLC 缺失高:进入缓存优化(1.6),不是 CPU 流水线问题;
  4. IPC 大于 1 但吞吐仍低:看是否受限于单线程依赖链,尝试循环展开/多累加器(1.5)。

1.4.9 Y86-64 编码全表与手写机器码

这一节回答:Y86-64 的每条指令在内存里到底占几个字节、字段怎么排?手写一遍机器码,流水线各阶段「取到什么、算出什么」才有具体对象,而不是停留在框图。

Y86-64 是一个教学用的 RISC 式 ISA:定长字段、load/store 分离、15 个寄存器、只有 ZF/SF/OF 三个条件码。它故意做得比 x86 简单,但保留了「取指—译码—执行—访存—写回—更新 PC」的完整链路。

指令格式

每条指令的第一个字节是 icode:ifun(高 4 位 icode,低 4 位 ifun)。需要寄存器操作数的指令再跟一个 rA:rB 字节;需要常量的指令跟 8 字节小端常量。

icode指令格式长度
0halt0:01
1nop1:01
2rrmovq rA,rB / cmovXX rA,rB2:fn rA:rB2
3irmovq V,rB3:0 F:rB V(8)10
4rmmovq rA,D(rB)4:0 rA:rB D(8)10
5mrmovq D(rB),rA5:0 rA:rB D(8)10
6OPq rA,rB6:fn rA:rB2
7jXX Dest7:fn Dest(8)9
8call Dest8:0 Dest(8)9
9ret9:01
Apushq rAA:0 rA:F2
Bpopq rAB:0 rA:F2

OPq 的 fn:0 add、1 sub、2 and、3 xor。jXX 的 fn:0 jmp、1 jle、2 jl、3 je、4 jne、5 jge、6 jg。cmovXX 复用 icode 2,fn 与 jXX 相同(0 表示无条件 rrmovq)。

寄存器编号与状态码

编号寄存器编号寄存器
0%rax8–14%r8–%r14
1%rcx15 (0xF)无(RNONE)
2%rdx
3%rbx
4%rsp
5%rbp
6%rsi
7%rdi

RNONE 用在「该字段不使用」的位置:irmovq 的 rA 必须是 0xF,popq 的 rB 必须是 0xF。状态码有四个:AOK=1、HLT=2、ADR=3(地址越界)、INS=4(非法指令)。

手写一段机器码

目标是 sum = 1 + 2 + ... + 10:

0x000: irmovq $10, %rcx       30 f1 0a 00 00 00 00 00 00 00
0x00a: irmovq $0, %rax        30 f0 00 00 00 00 00 00 00 00
0x014: addq   %rcx, %rax      60 10
0x016: irmovq $1, %rdx        30 f2 01 00 00 00 00 00 00 00
0x020: subq   %rdx, %rcx      61 21
0x022: jne    0x014           74 14 00 00 00 00 00 00 00
0x02b: halt                   00

逐条核对:

  • irmovq $10,%rcx:icode=3、ifun=0 → 0x30;rA=0xF、rB=1 → 0xF1;常量 10 的 8 字节小端。
  • addq %rcx,%rax:icode=6、fn=0 → 0x60;rA=rcx=1、rB=rax=0 → 0x10。Y86 的语义是 rB = rB OP rA,所以「加到 rax」要把 rax 放在 rB。
  • subq %rdx,%rcx:icode=6、fn=1 → 0x61;rA=rdx=2、rB=rcx=1 → 0x21。把 0x61 写成 0x60 是手写机器码最常见的错误:指令变成 addq,循环永不结束,寄存器一路累加。
  • jne 0x014:icode=7、fn=4 → 0x74;后面直接跟 8 字节绝对地址(没有 rA:rB 字节)。
  • halt:0x00。

注意 jXX 和 call 的常量从第 2 个字节开始,而 irmovq/rmmovq 从第 3 个字节开始。译码器必须先看 icode 决定有没有寄存器字节,再决定常量偏移——这是「变长指令取指」的第一个难点,也是 1.4.13 解释器里最容易写错的地方。

手算下一条 PC

执行到 0x020 的 subq 时,valP 是多少?指令 2 字节,valP = 0x020 + 2 = 0x022。而 0x022 的 jne 长度 9,valP = 0x02b;若条件成立,PC 被改成 valC = 0x014,否则用 valP。Y86 的顺序实现里,「预测 PC」默认是 valP,跳转成立时再覆盖——这正是流水线里「分支预测」要解决的矛盾。

1.4.10 HCL:把控制逻辑写成表达式

HCL(Hardware Control Language)是 CSAPP 用来描述处理器控制信号的 DSL。它不是 Verilog,也不是 C,而是一组「组合逻辑等式」:左边是信号名,右边是由输入信号构成的表达式,每个时钟周期重新求值。

三种类型

类型含义例
bool单根线bool need_regids = icode in { IRRMOVQ, IOPQ };
int64 位字(地址、数据)int srcA = ...;
word同 int,多用于数据通路word aluA = ...;

HCL 的表达式只有几类:&&、||、!、==、!=、in { ... }、算术、比较,以及选择表达式 [ cond : expr; ...; 1 : default; ]。选择表达式按顺序匹配,第一个为真的分支胜出,1 : 是兜底(相当于 C 的 else)。

一段真实的控制逻辑

取指阶段要决定「有没有寄存器字节」「读哪两个寄存器」:

bool need_regids =
    icode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ,
               IIRMOVQ, IRMMOVQ, IMRMOVQ };

int srcA = [
    icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA;
    icode in { IPOPQ, IRET }                     : RESP;
    1                                            : RNONE;
];

int srcB = [
    icode in { IOPQ, IRMMOVQ, IMRMOVQ }          : rB;
    icode in { ICALL, IPUSHQ, IPOPQ, IRET }      : RESP;
    1                                            : RNONE;
];

srcA/srcB 直接驱动寄存器堆的读端口;RNONE 表示「不读」。RESP 是 %rsp 的编号 4,用于隐式使用栈指针的指令。

执行阶段要决定 ALU 的两个输入和是否写回:

word aluA = [
    icode in { IRRMOVQ, IOPQ }                   : rvalA;
    icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ }       : valC;
    icode in { ICALL, IPUSHQ }                   : -8;
    icode in { IRET, IPOPQ }                     : 8;
    1                                            : 0;
];

word aluB = [
    icode in { IRMMOVQ, IMRMOVQ, IOPQ,
               ICALL, IPUSHQ, IPOPQ, IRET }      : rvalB;
    1                                            : 0;
];

bool set_cc = icode in { IOPQ };

读这段代码的窍门:每一行就是一个多路选择器(mux)。aluA 在「寄存器值 / 常量 / ±8」之间选,选择信号是 icode。综合工具会把它变成与或门;软件模拟器会把它变成 switch 或查表。

HCL 与 C、Verilog 的对照

概念HCLCVerilog
组合逻辑等式,每周期求值函数调用assign / always @(*)
选择[c1:e1; 1:e2;]c1 ? e1 : e2c1 ? e1 : e2
集合判断icode in {A,B}`icode==A
状态无(时钟边沿更新寄存器)变量always @(posedge clk)
位宽隐含 64 位由类型决定显式 [63:0]

HCL 里最容易犯的错是把组合逻辑写成「有状态」:x = x + 1 在 HCL 里不是自增,而是「x 等于 x+1」的循环定义,综合会报组合环。状态必须放在时钟更新的寄存器里(PC、CC、寄存器堆、内存)。

另一类错误是漏掉默认分支。[ cond : expr; ] 在 cond 为假时没有定义,综合出锁存器(latch)或仿真出 X;HCL 的惯例是永远写 1 : default;。这跟 C 的 switch 漏 default 不同:C 里漏掉只是不执行,硬件里漏掉会产生意外的存储元件。

为什么控制逻辑要用「表达式」而不是「指令序列」

组合逻辑没有「先算谁后算谁」:所有信号在同一时刻根据输入稳定下来。HCL 强制你用等式描述「输出 = f(输入)」,正好对应硬件的并联本质。写成 C 的 if/else 序列也能模拟,但会掩盖「同一个周期内所有 mux 同时选择」这个关键事实,也就很难理解流水线里为什么会出现「转发路径冲突」。

1.4.11 流水线手算:转发、暂停与气泡

这一节把五级流水线(F/D/E/M/W)跑在一个 6 条指令的小程序上,逐周期填表,算出一个真实的 CPI。所有规则都可以在纸上复现。

程序(接 1.4.9 的机器码;为了演示 load-use,把 I4 换成一条 load,地址仅作示意):

I1 0x000  irmovq $10, %rcx
I2 0x00a  irmovq $0, %rax
I3 0x014  addq   %rcx, %rax
I4 0x016  mrmovq 0(%rax), %rdx     # load,假设 rax 指向可读内存
I5 0x020  addq   %rdx, %rbx
I6 0x022  halt

假设:分支预测「不跳转」;ALU 结果可以从 E/M/W 转发;load 的数据只在 M 末尾可用;寄存器堆写口在 W 前半周期写、后半周期读(同一周期内可转发)。

无冒险时的理想表

指令        1  2  3  4  5  6  7  8  9 10
I1 irmovq   F  D  E  M  W
I2 irmovq      F  D  E  M  W
I3 addq           F  D  E  M  W
I4 mrmovq            F  D  E  M  W
I5 addq                 F  D  E  M  W
I6 halt                    F  D  E  M  W

6 条指令、5 级流水线,理想周期数是 5+(6−1)=105 + (6-1) = 10,CPI =10/6≈1.67= 10/6 \approx 1.67。流水线填满后每周期完成一条,长期 CPI 趋近 1。

加入 load-use 冒险

I5 在 D 阶段要读 %rdx,而 I4 是 load,数据要到 M 阶段末尾才回来。E 阶段开始时要用的值还没有,只能暂停一拍:

指令        1  2  3  4  5  6  7  8  9 10 11
I1 irmovq   F  D  E  M  W
I2 irmovq      F  D  E  M  W
I3 addq           F  D  E  M  W
I4 mrmovq            F  D  E  M  W
I5 addq                 F  D  D  E  M  W
I6 halt                    F  -  D  E  M  W

第 7 周期:I5 停在 D(表中第二个 D),E 阶段插入一个气泡(bubble),F 阶段的 I6 原地等待。总周期 11,CPI =11/6≈1.83= 11/6 \approx 1.83。代价是一拍,不是三拍——因为 load 的数据在 M 末尾可用,下一拍就能转发进 E。

load-use 的精确条件是:

E_icode == IMRMOVQ && (E_dstM == D_srcA || E_dstM == D_srcB)

满足时:F 保持 PC 不变、D 保持当前指令、E 插入气泡(把 E 的控制信号全部清零,等效于 nop)。

转发条件

E 阶段的两个源操作数 e_valA、e_valB 按优先级选择:

优先级来源条件
1W 阶段写回值W_dstE == E_srcA && W_dstE != RNONE
2M 阶段 ALU 结果M_dstE == E_srcA && M_dstE != RNONE
3寄存器堆读出的值默认

e_valB 同理,但还要考虑 M_dstM(load 的目的寄存器)——load 的结果要到 M 末尾才有,不能从 M 转发,只能暂停。这就是为什么「load-use 必须停一拍,而普通 ALU 依赖不用停」。

控制冒险

情况代价(CSAPP 五级)处理
条件跳转预测错误2 个气泡在 E 阶段算出条件后,把 PC 改到 valC,清空 D/E
ret3 个气泡返回地址在 W 阶段才写回,D 必须停到那时
预测「不跳转」但实际跳转2 个气泡同上;现代处理器用 BTB + 方向预测把代价降到接近 0
预测「跳转」但实际不跳2 个气泡需要能取消已取入的错误路径指令

现代处理器的分支预测早已不是「一律不跳」:BTB 记住跳转目标,两位饱和计数器记住方向,返回地址栈预测 ret,TAGE 一类预测器用长历史做索引。预测正确的代价是 0,预测错误才付 15–20 周期的流水线冲刷(深度越大越贵)。

手算 CPI

上表 11 个周期、6 条指令,CPI = 1.83。若循环执行 10 次(每次 5 条指令 + 1 条 jne),且 jne 预测正确、无 load-use,则每次迭代 5 个周期(I1–I5 各占一拍)加 jne 一拍,共 6 拍/迭代;10 次迭代 60 拍,加填充/排空约 5 拍,总计约 65 拍完成 60 条指令,CPI ≈ 1.08。

1.4.12 乱序执行的量化模型:ROB、窗口与 Little 定律

1.4.7 讲了重命名、ROB 与超标量的机制。这一节换一个角度:把乱序执行当成一个排队系统,用几个数字解释「为什么 IPC 上不去」。这比记住「ROB 是重排序缓冲」更有用,因为 perf 报出来的瓶颈最终都能落到这几个量上。

前端与后端

部分做什么关键结构典型瓶颈
前端取指、译码、送入 uopI-cache、BTB、uop cache、译码器I-cache 缺失、译码带宽、分支预测失败
重命名架构寄存器 → 物理寄存器RAT、空闲物理寄存器表物理寄存器耗尽
调度唤醒、选择、发射保留站、发射端口端口冲突、依赖链
执行ALU/load/store/FP执行单元、load/store 缓冲延迟、带宽、miss
退休按序提交、精确异常ROBROB 满、长延迟指令堵头

前端每周期最多取 16 字节(x86)、译码 4–6 条;后端每周期最多发射 4–8 个 uop。任何一段满了,IPC 就停在那里。

重命名解决什么、不解决什么

重命名消除的是假依赖(WAR/WAW):mov %rax,%rbx; mov %rcx,%rax 里第二条写 %rax 不必等第一条读完,因为两者被映射到不同物理寄存器。它不消除真依赖(RAW):add %rax,%rbx 仍要等 %rax 的上一个生产者。

内存依赖更麻烦:mov (%rdi),%rax; mov %rax,(%rsi) 有寄存器真依赖,容易处理;但 mov %rax,(%rdi); mov (%rsi),%rbx 是否有依赖,取决于 %rdi 与 %rsi 是否相等,直到地址算出来才知道。处理器用 load/store 队列做内存消歧(memory disambiguation):允许 load 先于未知地址的 store 执行(推测),发现别名再冲刷重放。这就是「4K 别名」一类性能异常的来源。

ROB 与 Little 定律

把 ROB 看成一个容量为 NN 的队列,指令在队列里停留的平均时间为 LL(从取指到退休),吞吐为 TT(即 IPC)。排队论的基本关系是:

N=T×L. N = T \times L .

反过来,给定 ROB 容量与平均停留时间,IPC 上限是 T≤N/LT \le N / L。举例:

场景NNLLIPC 上限
全部命中 L122430 周期7.5(受发射宽度限制,实际 4–6)
1% 指令 L3 缺失(200 周期)2240.99×30+0.01×230≈320.99\times30 + 0.01\times230 \approx 327.0(仍不是瓶颈)
10% 指令 DRAM 缺失(300 周期)2240.9×30+0.1×330=600.9\times30+0.1\times330=603.7
每个 load 都 miss,MLP=12243300.68

最后一行说明:ROB 再大,也救不了「一次只有一个 miss 在飞」的代码。要提高 IPC,要么减少 miss(分块、预取),要么提高 MLP(同时发多个独立 load)。ROB 的作用是让这些独立 load 能同时在飞——它提供的是「并发度预算」,不是「延迟消除」。

为什么 CPU 不把 ROB 做得无限大

ROB 每一项都要参与唤醒、选择、比较(最多 6 个发射口 × 每项),面积与功耗随容量超线性增长;而且越大的窗口越难做时序收敛。现代桌面 CPU 的 ROB 在 224–512 项,Apple M 系列更大(约 600+),但都不是无限。窗口大小是「平均 IPC」与「芯片成本」的折中,不是越大越好。

公开的微架构参数(量级)

处理器发射宽度ROB 项数L1D备注
Intel Skylake422432 KB2015–2019 主力
Intel Golden Cove651248 KB12 代酷睿
AMD Zen 4632032 KB5nm
Apple Firestorm8~630128 KBM1 性能核

数字来自厂商公开资料与第三方逆向,不同步进略有差异;用 perf stat 测出来的是实际行为,不要拿表格当保证。表格的用途是建立量级感:宽度从 4 到 8 只翻倍,而 L1 容量翻四倍,说明现代 CPU 更依赖「少 miss」而不是「更宽」。

1.4.13 完整程序一:Y86-64 解释器

这个程序实现 1.4.9 的 ISA 子集:取指、译码、执行、访存、写回、更新 PC。它不模拟流水线,只模拟指令集语义——先用它确认手写的机器码是对的,再去关心流水线为什么慢。

/* y86.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 y86.c */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

#define MEMSZ 4096
enum { RAX, RCX, RDX, RBX, RSP, RBP, RSI, RDI, R8, R9, R10, R11, R12, R13, R14, RNONE = 15 };
enum { AOK = 1, HLT = 2, ADR = 3, INS = 4 };

static uint8_t mem[MEMSZ];
static uint64_t reg[16];
static uint64_t pc;
static int ZF, SF, OF;
static int status = AOK;

static uint64_t rd64(size_t at)
{
    uint64_t v = 0;
    for (int i = 7; i >= 0; i--)
        v = (v << 8) | mem[at + (size_t)i];
    return v;
}
static void wr64(size_t at, uint64_t v)
{
    for (int i = 0; i < 8; i++)
        mem[at + (size_t)i] = (uint8_t)(v >> (8 * i));
}
static void set_cc(uint64_t a, uint64_t b, uint64_t r, int sub)
{
    ZF = (r == 0);
    SF = (r >> 63) & 1;
    if (sub)
        OF = ((a >> 63) != (b >> 63)) && ((r >> 63) != (a >> 63));
    else
        OF = ((a >> 63) == (b >> 63)) && ((r >> 63) != (a >> 63));
}
static int cond(int ifun)
{
    switch (ifun) {
    case 0: return 1;                 /* jmp / rrmovq */
    case 1: return (SF ^ OF) | ZF;    /* le */
    case 2: return SF ^ OF;           /* l  */
    case 3: return ZF;                /* e  */
    case 4: return !ZF;               /* ne */
    case 5: return !(SF ^ OF);        /* ge */
    case 6: return !(SF ^ OF) && !ZF; /* g  */
    default: return 0;
    }
}

static void step(void)
{
    if (pc >= MEMSZ) { status = ADR; return; }
    uint8_t byte = mem[pc];
    int icode = byte >> 4, ifun = byte & 0xF;
    uint8_t regbyte = (pc + 1 < MEMSZ) ? mem[pc + 1] : 0;
    int rA = regbyte >> 4, rB = regbyte & 0xF;
    uint64_t valC = 0, valP = pc + 2;
    int use_c = 0, has_reg = 0;

    switch (icode) {
    case 0x0: status = HLT; return;                 /* halt */
    case 0x1: break;                                /* nop */
    case 0x2: has_reg = 1; break;                   /* rrmovq/cmov */
    case 0x3: has_reg = 1; use_c = 1; break;        /* irmovq */
    case 0x4: has_reg = 1; use_c = 1; break;        /* rmmovq */
    case 0x5: has_reg = 1; use_c = 1; break;        /* mrmovq */
    case 0x6: has_reg = 1; break;                   /* OPq */
    case 0x7: use_c = 1; break;                     /* jXX */
    case 0x8: use_c = 1; break;                     /* call */
    case 0x9: break;                                /* ret */
    case 0xA: has_reg = 1; break;                   /* pushq */
    case 0xB: has_reg = 1; break;                   /* popq */
    default: status = INS; return;
    }
    if (use_c) valC = rd64(pc + (has_reg ? 2 : 1));
    valP = pc + (has_reg ? 2 : 1) + (use_c ? 8 : 0);

    uint64_t aluA = 0, aluB = 0, dst = 0;
    int write_reg = 0, write_mem = 0, setcc = 0, dst_reg = RNONE;
    uint64_t mem_addr = 0, mem_data = 0;

    switch (icode) {
    case 0x2:                                   /* rrmovq / cmovXX */
        aluA = reg[rA]; aluB = 0; dst = aluA + aluB;
        if (cond(ifun)) { write_reg = 1; dst_reg = rB; }
        break;
    case 0x3:                                   /* irmovq */
        dst = valC; write_reg = 1; dst_reg = rB;
        break;
    case 0x4:                                   /* rmmovq rA, D(rB) */
        mem_addr = reg[rB] + valC; mem_data = reg[rA]; write_mem = 1;
        break;
    case 0x5:                                   /* mrmovq D(rB), rA */
        mem_addr = reg[rB] + valC; write_reg = 1; dst_reg = rA;
        break;
    case 0x6:                                   /* OPq */
        aluA = reg[rA]; aluB = reg[rB];
        if (ifun == 0) dst = aluB + aluA;
        else if (ifun == 1) dst = aluB - aluA;
        else if (ifun == 2) dst = aluB & aluA;
        else if (ifun == 3) dst = aluB ^ aluA;
        else { status = INS; return; }
        write_reg = 1; dst_reg = rB; setcc = (ifun <= 1);
        break;
    case 0x7:                                   /* jXX */
        if (cond(ifun)) { pc = valC; return; }
        break;
    case 0x8:                                   /* call */
        reg[RSP] -= 8; wr64(reg[RSP], valP); pc = valC; return;
    case 0x9:                                   /* ret */
        pc = rd64(reg[RSP]); reg[RSP] += 8; return;
    case 0xA:                                   /* pushq */
        reg[RSP] -= 8; wr64(reg[RSP], reg[rA]); break;
    case 0xB:                                   /* popq */
        write_reg = 1; dst_reg = rA; break;
    case 0x1: break;
    }

    if (setcc) set_cc(aluB, aluA, dst, ifun == 1);
    if (write_mem) {
        if (mem_addr + 7 >= MEMSZ) { status = ADR; return; }
        wr64(mem_addr, mem_data);
    }
    if (icode == 0x5) {
        if (mem_addr + 7 >= MEMSZ) { status = ADR; return; }
        dst = rd64(mem_addr);
    }
    if (icode == 0xB) {
        if (reg[RSP] + 7 >= MEMSZ) { status = ADR; return; }
        dst = rd64(reg[RSP]); reg[RSP] += 8;
    }
    if (write_reg && dst_reg != RNONE) reg[dst_reg] = dst;
    pc = valP;
}

int main(void)
{
    static const uint8_t prog[] = {
        0x30, 0xf1, 0x0a, 0, 0, 0, 0, 0, 0, 0,   /* 0x000: irmovq $10,%rcx */
        0x30, 0xf0, 0x00, 0, 0, 0, 0, 0, 0, 0,   /* 0x00a: irmovq $0,%rax  */
        0x60, 0x10,                              /* 0x014: addq %rcx,%rax  */
        0x30, 0xf2, 0x01, 0, 0, 0, 0, 0, 0, 0,   /* 0x016: irmovq $1,%rdx  */
        0x61, 0x21,                              /* 0x020: subq %rdx,%rcx  */
        0x74, 0x14, 0, 0, 0, 0, 0, 0, 0, 0,      /* 0x022: jne 0x014       */
        0x00                                     /* 0x02b: halt            */
    };
    memcpy(mem, prog, sizeof prog);
    int steps = 0;
    while (status == AOK && steps < 1000) { step(); steps++; }
    printf("status=%d steps=%d pc=0x%llx ZF=%d SF=%d OF=%d\n",
           status, steps, (unsigned long long)pc, ZF, SF, OF);
    printf("rax=%llu rcx=%llu rdx=%llu\n",
           (unsigned long long)reg[RAX], (unsigned long long)reg[RCX],
           (unsigned long long)reg[RDX]);
    return status == HLT ? 0 : 1;
}

预期输出:

status=2 steps=43 pc=0x2b ZF=1 SF=0 OF=0
rax=55 rcx=0 rdx=1

status=2 是 HLT,steps=43 可以手算核对:2 条 irmovq + 10 次循环 ×(addq、irmovq、subq、jne)+ 最后 1 条 halt = 2 + 40 + 1 = 43。rcx 从 10 减到 0,rax 累加 10+9+…+1 = 55。

关键实现细节:

  • has_reg 决定常量从第 2 还是第 3 字节开始。jXX/call 没有寄存器字节,这个分支写错会让所有跳转目标变成 0——第一次运行时的现象是「PC 回到 0,程序从头再来」。
  • OPq 的语义是 rB = rB OP rA,所以 subq %rdx,%rcx 的字节是 61 21(rA=2、rB=1),不是 61 12。
  • set_cc 只对 addq/subq 更新 ZF/SF/OF;and/xor 在 Y86 中不设条件码(与 x86 不同,x86 的逻辑运算会设 ZF/SF 并清 CF/OF)。
  • popq 的目的寄存器是 rA,而 pushq 的源寄存器也是 rA;两者的 rB 都是 RNONE。

1.4.14 完整程序二:依赖链、ILP 与 IPC 的上界

流水线的 CPI 能不能小于 1,取决于「同一时刻有多少条独立指令在飞」。这个程序用一条 LCG 递推链和四条独立链做对比:前者受乘法延迟限制,后者受发射宽度/端口吞吐限制。

/* ilp.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 ilp.c
 * 运行:./ilp 20000000 0   (0=一条依赖链,1=四条独立链)
 */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

volatile unsigned long sink;

static double now(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}

int main(int argc, char **argv)
{
    unsigned long n = (argc > 1) ? strtoul(argv[1], NULL, 0) : 50000000UL;
    int mode = (argc > 2) ? atoi(argv[2]) : 0;
    unsigned long a = 1, b = 1, c = 1, d = 1;
    const unsigned long A = 1664525UL, C = 1013904223UL;

    double t0 = now();
    if (mode == 0) {
        for (unsigned long i = 0; i < n; i++) {
            a = a * A + C;
            __asm__ volatile("" : "+r"(a));      /* 阻止封闭形式优化 */
        }
    } else {
        for (unsigned long i = 0; i < n; i++) {
            a = a * A + C;
            b = b * A + C;
            c = c * A + C;
            d = d * A + C;
            __asm__ volatile("" : "+r"(a), "+r"(b), "+r"(c), "+r"(d));
        }
    }
    double dt = now() - t0;
    sink = a + b + c + d;

    unsigned long updates = (mode == 0) ? n : 4UL * n;
    printf("mode=%s updates=%lu time=%.3f s per-update=%.3f ns sink=%lu\n",
           mode == 0 ? "dependent" : "independent", updates, dt,
           dt * 1e9 / (double)updates, (unsigned long)sink);
    return 0;
}

在一台 Apple M1(arm64,clang 15,-O2)上的输出:

mode=dependent updates=20000000 time=0.034 s per-update=1.710 ns sink=7662709483399116036
mode=independent updates=80000000 time=0.034 s per-update=0.425 ns sink=12204093859886912516

四条独立链在同样的墙钟时间里做了四倍的更新,每次更新的成本从 1.71 ns 降到 0.425 ns(约 4 倍)。原因不是「独立链的乘法更快」,而是:

  • 依赖链里每条 imul 必须等上一条的结果,延迟约 3–4 周期,OoO 窗口再大也无能为力(真依赖)。
  • 四条链之间没有依赖,CPU 可以每周期发射多条 imul,把乘法器的吞吐吃满;限制从「延迟」变成「端口数量 + 前端宽度」。

这个实验也解释了为什么「循环展开 + 多累加器」能提速(1.5.5):它把一条长依赖链拆成几条短链,让延迟被并行掩盖。判断标准不是「展开几路」,而是「链数 × 单链延迟 ≥ 总周期」,或者用 perf stat 看 IPC 是否接近发射宽度。

为什么用空 asm 而不是 volatile 变量

把 a 声明为 volatile 会让每次读写都落到内存,测到的是 store-to-load 延迟,不是 ALU 延迟。__asm__ volatile("" : "+r"(a)) 告诉编译器「a 的值可能被这段空代码改变」,强制它把 a 留在寄存器里并逐次计算,同时不生成任何指令。这是微基准最常用的「优化屏障」。

1.4.15 实验:用 perf stat 读 IPC 与分支预测

perf stat 把「程序为什么慢」拆成可加的计数。先测 1.4.14 的两条路径:

$ cc -O2 -o ilp ilp.c
$ perf stat -e cycles,instructions,branches,branch-misses ./ilp 200000000 0
$ perf stat -e cycles,instructions,branches,branch-misses ./ilp 50000000 1

x86-64 上的输出形如:

 Performance counter stats for './ilp 200000000 0':

     601,234,567      cycles
     201,345,678      instructions              #    0.33  insn per cycle
       1,234,567      branches
          12,345      branch-misses             #    1.00% of all branches

       0.201  seconds time elapsed

读法:

  • IPC = instructions / cycles。依赖链版本约 0.33,意味着平均每 3 个周期才完成一条指令——正好对应乘法延迟;独立链版本 IPC 会接近 2–3,受发射宽度限制。
  • branch-misses 的百分比要与 branches 一起看。循环的 jne 每 10 次迭代错一次,miss 率约 10%,但绝对数很小;真正致命的是「每次迭代都随机跳」的代码。
  • cycles 与 time elapsed 的比值给出实际频率。若频率远低于标称值,先查功耗/温度限制,再谈 IPC。

第二个实验量化分支预测。程序把 100 万个随机字节与 128 比较,排序后同样的比较会变成「一长串连续 taken / 连续 not-taken」,两位预测器几乎全中:

/* bpred.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 bpred.c
 * 运行:./bpred 0(随机)  ./bpred 1(排序后)
 */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N (1u << 20)

static int cmp(const void *x, const void *y)
{
    return (int)*(const unsigned char *)x - (int)*(const unsigned char *)y;
}

static double now(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}

int main(int argc, char **argv)
{
    static unsigned char a[N];
    unsigned seed = 1;
    for (unsigned i = 0; i < N; i++) {
        seed = seed * 1103515245u + 12345u;
        a[i] = (unsigned char)(seed >> 16);
    }
    int sorted = (argc > 1) ? atoi(argv[1]) : 0;
    if (sorted)
        qsort(a, N, 1, cmp);

    long sum = 0;
    double t0 = now();
    for (unsigned i = 0; i < N; i++) {
        if (a[i] >= 128) {
            sum += a[i];
            __asm__ volatile("" : "+r"(sum));   /* 阻止向量化,保留真分支 */
        }
    }
    double dt = now() - t0;
    printf("sorted=%d sum=%ld time=%.3f ms\n", sorted, sum, dt * 1e3);
    return 0;
}
$ cc -O2 -o bpred bpred.c
$ ./bpred 0
sorted=0 sum=100450803 time=5.770 ms
$ ./bpred 1
sorted=1 sum=100450803 time=0.518 ms
$ perf stat -e branches,branch-misses ./bpred 0
$ perf stat -e branches,branch-misses ./bpred 1

预期:随机版本的分支 miss 率约 50%(每次比较都像抛硬币),排序后降到 0.1% 以下;时间差约 10 倍。这个实验说明:

  • 分支预测器的输入是历史,不是数据分布;随机数据没有可利用的历史。
  • 把「数据排序」这种算法层改动带来越来越大的收益,是因为它把「控制冒险」变成了「可预测的控制流」。
  • 用 cmov 把分支改成数据依赖(1.3.4)也能消除 miss,但会把两条路径都算一遍;数据随机时通常划算,分支高度可预测时反而多做事。
读 perf stat 的顺序
  1. 先看 instructions 与 cycles 的比值(IPC),与预期上限比较。
  2. IPC 低:看 branch-misses(控制冒险)、stalled-cycles-frontend(取指/译码)、stalled-cycles-backend(执行/访存)。
  3. IPC 高但慢:看频率、cache-misses、dTLB-load-misses,以及是否在做无用功(指令数本身太多)。
  4. 换 -e 列表细化到具体端口或缓存层,例如 perf stat -e L1-dcache-load-misses,LLC-load-misses。

1.4.16 三种 ISA 上的流水线:同一套五级,不同的前端

1.4.11 的五级流水线是通用模型,但把它落到 x86-64、AArch64、RISC-V 上,前端的复杂度差别很大。这一节回答:为什么 x86 的译码器那么贵、为什么 RISC-V 适合做教学 ISA、同一段 C 在三种架构上「流水线行为」差在哪。后面附一个 C/Rust 双版本的 IPC 实验。

与流水线相关的 ISA 差异

维度x86-64AArch64RV64GC
指令长度1–15 字节,变长固定 4 字节固定 4 字节;C 扩展混入 2 字节
取指需先定界,易跨取指块按 4 字节对齐切分需处理 2/4 字节混合对齐
译码译码成 uop,复杂指令拆多条一条指令一个(或少数)uop一条指令一个 uop
条件码RFLAGS,算术指令隐式写NZCV,仅 *S 指令写无,分支直接比较
分支cmp/test + jcccmp + b.cond,或 cbz/tbnzbeq/bne/blt/bge 直接比较
条件传送cmovcsel/csinc无,需分支或位运算
内存操作数算术指令可直接读内存load/store 分离load/store 分离
通用寄存器1631(+xzr)32(x0 恒 0)
典型前端宽度4–6 uop/周期4–84–8(视实现)
典型分支惩罚15–20 周期10–15 周期5–10 周期(依流水深度)

这张表解释了三件事:

  1. x86 的前端最贵。变长指令要先确定边界,复杂指令要拆成多个 uop;现代 x86 用 uop cache 把「译码一次」的结果缓存起来,热代码直接取 uop,绕开译码器。ARM64/RISC-V 的定长编码让取指与译码简单得多,代价是代码密度通常更低(同样逻辑占更多 I-cache)。
  2. x86 的标志寄存器是一个「隐式目的地」。每条写标志的指令都在写一个所有分支共享的寄存器,重命名需要额外处理;RISC-V 没有这个问题,分支自带比较,不存在「标志位假依赖」。AArch64 折中:只有带 S 后缀的指令写 NZCV。
  3. RISC-V 最适合讲五级流水线。没有标志位、没有内存操作数、没有条件传送、寄存器多、编码定长,控制逻辑几乎是 1.4.10 的 HCL 直译。CSAPP 用 Y86-64 也是同样的思路:先把 ISA 简化到「每条指令都能在一个阶段做完」,再讨论冒险。

同一段循环在三种架构上的形态

以 s += i; i++ 为例(i 在寄存器、n 在另一个寄存器):

架构指令序列说明
x86-64add %rcx,%rax;inc %rcx;cmp %rcx,%rdi;jge4 条,cmp 设标志
AArch64add x0,x0,x2;add x2,x2,#1;cmp x1,x2;b.ge4 条,cmp 设 NZCV
RISC-Vadd a0,a0,t0;addi t0,t0,1;bge a1,t03 条,分支自带比较

流水线视角下,三条序列的关键路径都是「i 的增量 → 下一次比较 → 分支」,长度 1–2 周期;差别在 uop 数与前端压力。x86 的 cmp+jge 在译码后可能融合(macro-fusion)成一个比较分支 uop,实际发射数与 RISC-V 接近。不要用源码行数推断性能,要用 perf stat 的 instructions 与 uops_issued(若可用)。

分支解析在哪一级

架构分支条件何时可用典型解析级预测错误代价
经典五级(Y86/RISC-V 教学)E 阶段 ALU 后E2 个气泡
现代 x86专用分支单元,早期计算前端/整数执行15–20 周期
现代 AArch64专用分支单元 + 条件标志前递前端/执行10–15 周期
现代 RISC-V(如 BOOM)执行级或更早E/前端5–10 周期

流水线越深、频率越高,预测错误越贵。这就是为什么现代处理器把大量面积花在分支预测(BTB、TAGE、返回栈)而不是 ALU 上:预测正确时,分支在流水线里几乎「消失」;预测错误时,再快的 ALU 也救不回来。

CPU 组成的三架构对照

部件x86-64AArch64RV64GC
PCRIP,不能直接读PC,不能直接读pc,auipc 可读高位
指令寄存器/预译码变长,需预译码固定 4 字节4 字节 + 2 字节压缩
译码器复杂,微码/多 uop规则,单 uop 为主规则,单 uop 为主
寄存器堆16×64,支持 8/16/32 位别名31×64 + 零寄存器32×64,x0 硬连线为 0
ALU/标志ALU + RFLAGSALU + 可选 NZCV纯 ALU,无标志
分支条件码驱动条件码或 cbz/tbnz比较器直接驱动
访存复杂寻址、内存操作数基址+偏移,load/store基址+偏移,load/store
特权/异常IDT、MSR、CPL异常向量、系统寄存器CSR、异常向量

C 与 Rust 双版本 IPC 实验

同一段依赖链/独立链基准,C 与 Rust 各写一遍。Rust 版本用 std::hint::black_box 阻止常量折叠,编译产物与 C 版本一样是普通 ELF,perf stat 的用法完全相同。

C17(1.4.14 的 ilp.c,节选主循环)
for (unsigned long i = 0; i < n; i++) {
    a = a * A + C;
    b = b * A + C;
    c = c * A + C;
    d = d * A + C;
    __asm__ volatile("" : "+r"(a), "+r"(b), "+r"(c), "+r"(d));
}
Rust 2021(`rustc -O ilp.rs`,`black_box` 稳定于 1.66)
// ilp.rs — rustc -O ilp.rs -o ilp_rs
use std::hint::black_box;
use std::time::Instant;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let n: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(50_000_000);
    let mode: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
    let (mut a, mut b, mut c, mut d) = (1u64, 1u64, 1u64, 1u64);
    const A: u64 = 1664525;
    const C: u64 = 1013904223;

    let t0 = Instant::now();
    if mode == 0 {
        for _ in 0..n {
            a = a.wrapping_mul(A).wrapping_add(C);
            black_box(a);
        }
    } else {
        for _ in 0..n {
            a = a.wrapping_mul(A).wrapping_add(C);
            b = b.wrapping_mul(A).wrapping_add(C);
            c = c.wrapping_mul(A).wrapping_add(C);
            d = d.wrapping_mul(A).wrapping_add(C);
            black_box((a, b, c, d));
        }
    }
    let dt = t0.elapsed().as_secs_f64();
    let updates = if mode == 0 { n } else { 4 * n };
    println!(
        "mode={} updates={} time={:.3} s per-update={:.3} ns",
        if mode == 0 { "dependent" } else { "independent" },
        updates,
        dt,
        dt * 1e9 / updates as f64
    );
    black_box((a, b, c, d));
}

运行与观测:

$ rustc -O ilp.rs -o ilp_rs
$ ./ilp_rs 20000000 0
$ ./ilp_rs 20000000 1
$ perf stat -e cycles,instructions,branches,branch-misses ./ilp_rs 200000000 0

Rust 版本的行为与 C 版本一致:依赖链版本每次更新约 1.5–2 ns,独立链版本约 0.4–0.5 ns(具体数值取决于机器)。Rust 特有的两个干扰项要排除:

  • 溢出检查:rustc -O 默认关闭溢出检查;若用 cargo build --release 且 profile 里开了 overflow-checks = true,每次乘法后会多一条溢出分支,测到的就不是纯 ALU 延迟。
  • 边界检查:如果基准用 Vec 下标而不是固定变量,每次访问都可能有 panic_bounds_check 分支;用迭代器或 get_unchecked 才能与 C 对齐。
Rust 在系统编程里的位置

Rust 不是「替代 C」,而是与 C 共用同一套 ABI、同一套 ELF、同一套系统调用。Linux 内核从 6.1 起支持 Rust(CONFIG_RUST),用户态则有 rustix、nix、io_uring 绑定等库。对本课程而言,重要的是:机器码层面的知识是共通的,寄存器、栈帧、调用约定、缓存行不会因为语言而改变;语言只改变「谁负责检查」。

1.4.17 十二条处理器与流水线陷阱

#现象原因修复
1HCL 综合出锁存器/仿真出 X选择表达式漏了默认分支永远写 1 : default;
2控制信号随输入变化但下游不更新把组合逻辑当成了时序逻辑状态只在时钟边沿更新;HCL 等式无记忆
3load 后紧跟使用,结果错只检查了 dstE,没检查 load 的 dstM加 load-use 暂停条件
4转发拿到旧值优先级写反(M 覆盖了 W)W 阶段的值最新,优先级最高
5分支预测错误后寄存器被污染错误路径的指令没有被清空冲刷 D/E,禁止错误路径写回
6ret 之后取到错误指令返回地址在 W 才可用,D 没停ret 在 D 暂停到 W,或加返回栈预测
7流水线寄存器初值是随机数复位时没把控制信号清零气泡 = 所有控制信号置为 nop 的编码
8认为 CPI 不可能小于 1只见过单发射五级流水线超标量每周期可完成多条;CPI 的下界是 1/宽度
9用主频估算性能忽略了 IPC 与缓存时间 = 指令数 × CPI / 频率,三项都要看
10perf 的 IPC 与手算不符instructions 含内核/中断,或被多线程分摊用 -e instructions:u 限定用户态,绑定 CPU
11Y86 跳转目标总是 0以为 jXX 也有 rA:rB 字节jXX/call 的常量从第 2 字节开始
12Y86 循环不终止subq 写成 0x60(addq)OPq 的 fn 在低 4 位:sub 是 0x61
三个关于流水线的误解
  1. 「流水线加深一定更快」——深度增加会提高分支预测失败的代价(15–20 周期),也会增加流水线寄存器开销;Pentium 4 的 31 级流水线就是反例。
  2. 「乱序执行会改变程序结果」——ISA 保证按序语义;乱序只影响时序,退休仍按程序顺序,异常也是精确的。
  3. 「IPC 越高程序越快」——IPC 高可能只是指令多而简单;比较两个程序要看「完成同一任务的时间」,不是 IPC。

1.4.18 本章出口检查

检查项达标线没达标去哪
区分 ISA 与微架构能各举一例1.4.1
写出 Y86-64 的 irmovq 编码能解释每个字节1.4.2
画出五级流水线时间图标出 F/D/E/M/W1.4.4
判断转发能否解决某个冒险能识别 load-use1.4.5
用 CPI 公式估算分支代价会算 90%/99% 的差别1.4.6
解释重命名与 ROB 的分工能说出 WAW/WAR 如何消失1.4.7
用 perf stat 判断瓶颈能读 IPC 与 branch-misses1.4.8
即时自测 · 1.4

Q1 五级流水线中,addq %rax,%rbx 后紧跟 mrmovq (%rbx),%rcx,需要暂停吗?为什么?

Q2 分支指令占 15%,预测准确率 95%,失败代价 12 周期,基础 CPI 1。求实际 CPI。若准确率提高到 99%,提升多少?

Q3 寄存器重命名消除的是哪类冒险?RAW 冒险能消除吗?

Q4 一个程序 perf stat 显示 IPC=0.4、branch-misses=18%、cache-misses=2%。最可能的瓶颈是什么?先查什么?

答案

A1 不需要暂停。ALU 结果在 E 阶段末产生,mrmovq 在下一个周期的 E 阶段才用它,可以通过 E→M 转发路径把 %rbx 的新值送过去。只有 load-use(mrmovq 后紧跟使用其结果)才需要暂停,因为数据在 M 阶段末才出来。

A2 CPI=1+0.15×0.05×12=1.09CPI = 1 + 0.15 \times 0.05 \times 12 = 1.09;99% 时 1+0.15×0.01×12=1.0181 + 0.15 \times 0.01 \times 12 = 1.018。加速比 1.09/1.018≈1.071.09/1.018 \approx 1.07,约 7%。分支占比越高、失败代价越大,收益越明显。

A3 重命名消除 WAW 与 WAR(假依赖),因为它们只是「复用同一个架构寄存器名」造成的;RAW 是真依赖,必须靠转发/暂停/等待,不能消除。

A4 IPC 低且分支缺失率高,最可能是分支预测受限(热循环里有不可预测的分支)。先看 perf record 的热点函数与 perf annotate 的分支指令,再考虑把分支改成无分支或用查表;缓存缺失率只有 2%,不是首要矛盾。

母本对应

母本章节本书落点程度
C(CSAPP)第 4 章 4.1 Y86-64 指令集体系结构1.4.1、1.4.2完整:ISA、编码、指令语义
C(CSAPP)第 4 章 4.2 逻辑设计与 HCL1.4.3完整:HCL 组合逻辑
C(CSAPP)第 4 章 4.3 Y86-64 的顺序实现1.4.3完整:六阶段与 SEQ
C(CSAPP)第 4 章 4.4 流水线通用原理1.4.4完整:吞吐、延迟、流水寄存器
C(CSAPP)第 4 章 4.5 Y86-64 流水线实现1.4.4–1.4.6完整:冒险、转发、暂停、预测
R(奔跑吧)第 1 章 处理器体系结构1.4.7、1.4.8精选:ARM 流水线、OoO、内存模型
L(李治军)第 4 章 线程切换与调度1.4.8背景:上下文切换保存的硬件状态
O(OSTEP)第 6 章 受限直接执行1.4.8背景:用户态/内核态切换的硬件代价
K(Kerrisk)第 3 章 系统编程概念1.4.8背景:系统调用的流水线代价

下一站:知道了 CPU 怎么执行,下一步是让它执行得更快。哪些优化编译器能做、哪些必须你来写?循环展开、多累加器、分块为什么有效?进入 1.5 程序性能优化。