第 4 章:处理器体系结构
主题:处理器体系结构 (Processor Architecture)
目标:借助 Y86-64 理解指令如何在硬件中执行,重点掌握流水线、冒险处理和性能分析,并把这些概念映射到高性能科学计算软件的优化实践。
1. 本章核心视角
第 3 章从软件侧观察机器指令,第 4 章则向下追问:一条指令进入处理器后,数据怎样流动,状态何时更新,多条指令又怎样重叠执行?
flowchart LR
A["程序与编译器"] --> B["Y86-64 指令序列"]
B --> C["SEQ<br/>顺序实现"]
C --> D["PIPE<br/>流水线实现"]
D --> E["延迟、吞吐与冒险"]
E --> F["软件性能优化"]
对软件方向而言,本章不要求记住每根控制线,而应建立三层联系:
| 层次 | 需要掌握的问题 |
|---|---|
| ISA | 一条指令承诺了什么可见行为? |
| 微体系结构 | 处理器如何实现这些行为并维持正确性? |
| 软件性能 | 依赖、分支和访存模式如何影响流水线吞吐? |
重点:ISA 决定“结果必须是什么”,微体系结构决定“怎样得到结果”。SEQ 和 PIPE 可以实现同一套 Y86-64 ISA,但性能差异很大。
2. Y86-64 指令集速览
Y86-64 是为教学简化的 x86-64 子集。它保留寄存器、内存、条件码、跳转和过程调用等核心概念,同时去掉复杂寻址和大量历史兼容设计。
| 指令类别 | 示例 | 作用 |
|---|---|---|
| 数据传送 | rrmovq, irmovq, rmmovq, mrmovq | 寄存器、立即数与内存之间传送数据 |
| 整数运算 | addq, subq, andq, xorq | 运算并设置条件码 |
| 控制转移 | jmp, jle, jl, je, jne, jge, jg | 根据条件码选择下一条指令 |
| 条件传送 | cmovXX | 条件成立时写寄存器 |
| 过程调用 | call, ret, pushq, popq | 操作栈和控制流 |
| 控制指令 | halt, nop | 停机或不执行有效工作 |
处理器可见状态包括:
flowchart TD
S["Y86-64 体系结构状态"] --> P["程序计数器 PC"]
S --> R["15 个程序寄存器"]
S --> C["条件码 ZF / SF / OF"]
S --> M["内存"]
S --> T["状态码 Stat"]
状态码用于表示正常执行、停机、非法指令和非法地址等情况:
| 状态 | 含义 |
|---|---|
AOK | 正常执行 |
HLT | 执行 halt |
INS | 非法指令编码 |
ADR | 非法内存地址 |
3. 从逻辑电路到 SEQ
组合逻辑根据当前输入立即计算输出,时序逻辑则通过寄存器保存状态。时钟到达时,处理器把本周期算出的下一状态写入状态寄存器。
| 元件 | 作用 |
|---|---|
| 组合逻辑 | ALU、加法器、多路选择器、条件判断 |
| 寄存器 | 保存 PC、流水线中间结果等少量状态 |
| 寄存器文件 | 读取和写入程序寄存器 |
| 随机访问存储器 | 指令存储器与数据存储器 |
| 时钟 | 规定状态更新的边界 |
HCL (Hardware Control Language) 用逻辑表达式描述控制信号。例如,多路选择器本质是在若干候选值中按条件选择一个:
word out = [
cond_a : value_a;
cond_b : value_b;
1 : default_value;
];
SEQ 让一条指令在一个时钟周期内走完所有阶段:
flowchart LR
F["Fetch<br/>取指"] --> D["Decode<br/>译码"]
D --> E["Execute<br/>执行"]
E --> M["Memory<br/>访存"]
M --> W["Write Back<br/>写回"]
W --> P["Update PC<br/>更新 PC"]
SEQ 的优点是控制直观,缺点是时钟周期必须覆盖最慢指令的完整路径。即使当前只是简单的 nop,也要等待按最坏情况设计的长时钟周期。
4. 为什么需要流水线
流水线把一条长组合逻辑路径切成多个阶段,并在阶段之间加入流水线寄存器。不同指令可以同时占据不同阶段,从而提高单位时间内完成的指令数。
flowchart LR
F["F:取指"] --> FD["D 寄存器"]
FD --> D["D:译码"]
D --> DE["E 寄存器"]
DE --> E["E:执行"]
E --> EM["M 寄存器"]
EM --> M["M:访存"]
M --> MW["W 寄存器"]
MW --> W["W:写回"]
假设一条指令被分为 5 个阶段,理想时序如下:
| 指令 / 周期 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
I1 | F | D | E | M | W | ||
I2 | F | D | E | M | W | ||
I3 | F | D | E | M | W |
流水线改善的是吞吐率,不一定缩短单条指令的延迟:
| 指标 | 含义 |
|---|---|
| 延迟 (Latency) | 一条指令从进入到完成所需的时间 |
| 吞吐率 (Throughput) | 单位时间内完成的指令数量 |
| CPI | 平均每条指令消耗的时钟周期数 |
| IPC | 平均每周期完成的指令数,单发射流水线中约为 1 / CPI |
| CPE | 平均处理一个数据元素所需的周期数,常用于评价循环内核 |
5. 流水线性能模型
设流水线有 个阶段,第 个阶段的组合逻辑延迟为 ,流水线寄存器额外开销为 ,则时钟周期近似为:
T_clk = max(τ_i) + d
理想情况下,执行 条指令需要:
T_total ≈ (k + n - 1) * T_clk
当 很大且流水线填满后,单发射处理器接近每周期完成一条指令,即 CPI ≈ 1。实际 CPI 可粗略理解为:
CPI ≈ 1 + 每条指令平均引入的停顿/气泡数
流水线加深并非免费午餐:
| 限制 | 后果 |
|---|---|
| 阶段不均衡 | 时钟仍由最慢阶段决定 |
| 流水线寄存器开销 | 阶段过细后收益递减 |
| 数据依赖 | 后继指令可能必须等待结果 |
| 分支预测失败 | 已取入流水线的错误路径指令被清空 |
| Cache Miss | 后端长时间等待内存数据 |
现代 CPU 通常还会采用超标量、乱序执行、寄存器重命名和多级缓存。Y86-64 PIPE 是简化的单发射顺序流水线,但它揭示的依赖和吞吐问题仍然成立。
6. PIPE 的五个阶段
6.1 Fetch:取指
取指阶段根据当前 PC 读取指令字节,解析 icode、ifun、寄存器编号和立即数,并计算顺序下一地址 valP。
PC 的候选来源包括:
- 预测的顺序地址或分支目标。
- 条件分支预测失败后的正确地址。
ret从栈中取出的返回地址。
6.2 Decode:译码
译码阶段确定源寄存器 srcA/srcB 和目的寄存器 dstE/dstM,从寄存器文件读取 valA/valB。
PIPE 还会在此阶段执行数据转发:如果最新结果尚未写回寄存器文件,就直接从后续流水线阶段取得它。
6.3 Execute:执行
执行阶段由 ALU 计算算术结果或有效地址,并根据条件码判断条件跳转、条件传送是否成立。
| 指令 | ALU 的典型工作 |
|---|---|
OPq | 计算算术/逻辑结果 |
rmmovq/mrmovq | 计算内存有效地址 |
call/pushq | 栈指针减 8 |
ret/popq | 栈指针加 8 |
6.4 Memory:访存
访存阶段根据指令读写数据内存。载入指令直到本阶段末尾才能得到 valM,这也是 load/use 冒险无法仅靠普通转发完全消除的原因。
6.5 Write Back:写回
写回阶段把 valE 或 valM 写入目的寄存器。寄存器文件通常允许同一周期先写后读,使写回值能被当前译码阶段看到。
7. 数据冒险与转发
当后继指令需要前驱指令尚未写回的结果时,就发生数据冒险。Y86-64 中主要关注 RAW (Read After Write) 真依赖。
addq %rax, %rbx # 产生新的 %rbx
subq %rbx, %rcx # 立即读取 %rbx
如果坚持等 %rbx 写回寄存器文件,流水线要停多个周期。转发可以把 ALU 结果直接送到需要它的阶段:
flowchart LR
E["E:新结果 e_valE"] --> D["D:选择 valA / valB"]
M1["M:ALU 结果 M_valE"] --> D
M2["M:加载结果 m_valM"] --> D
W1["W:W_valE"] --> D
W2["W:W_valM"] --> D
译码阶段按“离当前最近、数据最新”的优先级选择转发源,避免读到旧寄存器值。
7.1 load/use 冒险
mrmovq 0(%rdi), %rax
addq %rax, %rbx
mrmovq 的数据要到 M 阶段末尾才可用,而下一条 addq 下一周期就要进入 E 阶段,因此必须插入一个气泡:
| 周期 | mrmovq | addq | 处理 |
|---|---|---|---|
| 1 | F | ||
| 2 | D | F | |
| 3 | E | D | 检测到 load/use |
| 4 | M | D | F、D 停顿,E 插入气泡 |
| 5 | W | E | 从 M/W 路径取得加载值 |
结论:转发减少等待,但不能让尚未产生的数据穿越时间。load/use 仍需要至少一次停顿。
7.2 stall 与 bubble
| 操作 | 含义 | 典型用途 |
|---|---|---|
| Stall | 保持流水线寄存器原值,不接收新输入 | 等待依赖数据或返回地址 |
| Bubble | 向流水线寄存器写入 nop 状态 | 取消错误路径或空出一个执行周期 |
可以把 Stall 理解为“当前指令别动”,把 Bubble 理解为“让这一格变成无副作用的空操作”。
8. 控制冒险
8.1 条件分支
取指阶段必须先猜下一条 PC,但条件跳转的真实方向要到 E 阶段才能确定。PIPE 通常预测条件跳转会执行,到 E 阶段发现预测错误时:
- 选择正确的后继 PC。
- 把错误路径上处于 D、E 附近的年轻指令变成气泡。
- 从正确路径重新取指。
flowchart TD
A["取到条件跳转"] --> B["预测跳转并继续取指"]
B --> C["E 阶段计算条件"]
C --> D{"预测正确?"}
D -- 是 --> E["流水线继续"]
D -- 否 --> F["清空错误路径"]
F --> G["从正确 PC 重新取指"]
分支预测失败的代价约等于从取指到解析分支之间的流水线深度。流水线越深,预测错误通常越昂贵。
8.2 ret 冒险
ret 的下一条 PC 保存在栈中,要到访存阶段才能读出。在返回地址可用前,取指阶段无法确定正确地址,因此需要暂停取指,并向下游插入气泡。
真实处理器使用返回地址栈 (Return Address Stack, RAS) 预测 ret,而教学版 PIPE 主要用停顿展示问题本质。
8.3 分支对科学计算内核的影响
规则网格内部通常执行相同计算,分支容易预测;边界条件、材料模型切换和接触判断则可能让分支结果随网格单元变化。
常见的软件策略:
- 把内部单元和边界单元拆成不同循环。
- 按单元类型或边界类型预分组,再批量处理。
- 对简单条件尝试条件传送或掩码计算,但先测量再决定。
- 避免为了“无分支”无条件执行昂贵的两侧计算。
9. PIPE 控制逻辑速查
控制逻辑必须保证异常情况的优先级一致,不能让同一个流水线寄存器同时 Stall 和 Bubble。
| 情况 | F | D | E | 目的 |
|---|---|---|---|---|
| 正常执行 | Load | Load | Load | 每周期推进 |
| load/use | Stall | Stall | Bubble | 等待加载结果 |
| 分支预测失败 | Load 正确 PC | Bubble | Bubble | 丢弃错误路径 |
ret 尚未取得地址 | Stall | Bubble | Load/Bubble | 阻止继续取错指令 |
简化后的判断思路:
if E 是 load/pop 且 E_dstM 被 D 阶段使用:
stall F
stall D
bubble E
if E 阶段发现条件分支预测错误:
bubble D
bubble E
if ret 位于 D/E/M 且返回地址尚不可用:
stall F
bubble D
实际 HCL 还要处理 load/use、分支错误和
ret同时出现时的优先级,不能把上面的伪代码机械翻译成互不相关的if。
10. 异常与精确状态
处理器可能遇到非法地址、非法指令和 halt。流水线中同时存在多条指令,因此不能在发现异常的瞬间随意停机,否则更老指令可能尚未提交,年轻指令却可能已经修改状态。
PIPE 把状态码随指令一起向后传递,并遵守:
- 异常指令之前的指令允许完成。
- 异常指令及之后的年轻指令不能产生错误的可见状态更新。
- 最终报告与顺序执行模型一致的精确异常位置。
这与现代 CPU 的“按程序顺序提交”思想相通:内部可以并行、预测甚至乱序,但软件看到的体系结构状态仍像按顺序执行。
11. 从流水线看软件性能
11.1 关键路径与循环依赖
下面的归约循环存在跨迭代依赖:下一次加法必须等待上一次 sum 的结果。
double sum_array(const double *a, size_t n)
{
double sum = 0.0;
for (size_t i = 0; i < n; i++)
sum += a[i];
return sum;
}
即使加载和循环控制可以重叠,单一累加器仍形成串行依赖链。展开循环并使用多个累加器,可以提供更多独立工作:
double sum_array_4(const double *a, size_t n)
{
double s0 = 0.0, s1 = 0.0, s2 = 0.0, s3 = 0.0;
size_t i = 0;
for (; i + 3 < n; i += 4)
{
s0 += a[i];
s1 += a[i + 1];
s2 += a[i + 2];
s3 += a[i + 3];
}
for (; i < n; i++) s0 += a[i];
return (s0 + s1) + (s2 + s3);
}
多个累加器可以提高指令级并行度,也更容易触发 SIMD 向量化,但会改变浮点加法顺序,结果可能有末位差异。
11.2 编译器需要看见独立性
| 障碍 | 编译器的顾虑 | 可行做法 |
|---|---|---|
| 指针可能别名 | 写入一个数组可能影响另一个数组 | 在契约真实成立时使用 restrict |
| 循环存在跨迭代依赖 | 重排会改变结果 | 重构算法或显式拆分依赖链 |
| 函数调用不可见 | 不知道调用是否有副作用 | 内联小函数或启用 LTO |
| 分支复杂 | 难以向量化和预测 | 分组处理、简化热循环 |
| 数据布局分散 | 连续 SIMD 加载困难 | 在合适场景采用 SoA 布局 |
restrict是程序员给编译器的承诺,违反承诺会导致未定义行为;它不是“加上就变快”的装饰品。
11.3 延迟、吞吐和带宽要分开
一个循环慢,可能是不同瓶颈造成的:
flowchart TD
A["循环耗时高"] --> B{"主要等待什么?"}
B --> C["依赖链<br/>Latency Bound"]
B --> D["执行端口<br/>Throughput Bound"]
B --> E["Cache / 内存<br/>Bandwidth or Latency Bound"]
B --> F["分支恢复<br/>Control Bound"]
- 依赖链受单次操作延迟限制,增加同类执行单元也未必有用。
- 独立操作很多时,瓶颈可能变成执行端口吞吐率。
- 数据不在 Cache 中时,流水线可能因内存等待而“断粮”。
- 难预测分支会反复清空前端已经完成的工作。
12. 面向船海工业仿真的重点
船舶水动力、CFD、有限元和多物理场软件常把大部分时间消耗在少数计算内核中。理解流水线的目的不是手写处理器,而是解释这些内核为何没有达到峰值性能。
| 典型内核 | 流水线视角 | 优化关注点 |
|---|---|---|
| 向量更新、SAXPY 类操作 | 独立迭代多,但常受内存带宽限制 | 连续访问、SIMD、减少不必要读写 |
| 点积与全局归约 | 累加器形成依赖链 | 多累加器、向量归约、并行树形归约 |
| 结构网格 Stencil | 邻域加载多,复用依赖 Cache | 分块、循环顺序、边界与内部拆分 |
| 稀疏矩阵向量乘 | 间接访问导致 Cache Miss | 存储格式、重排序、预取和负载均衡 |
| 通量/Riemann 求解器 | 浮点计算密集且可能有分支 | 内联、SIMD、减少不可预测分支 |
| 非结构网格遍历 | 地址和索引依赖链较长 | 数据布局、重编号、批量处理 |
12.1 一个重要判断:算力受限还是访存受限
流水线优化主要提高核心执行效率,但若算法已经受内存带宽限制,单纯减少几条算术指令不会带来明显收益。
实际分析顺序建议:
- 用剖析工具找到真正的热点函数和循环。
- 判断瓶颈来自计算、依赖、分支,还是 Cache/内存。
- 检查数据布局、访问连续性和循环顺序。
- 查看编译器是否完成内联、循环展开和向量化。
- 最后再考虑手工 SIMD、预取或更激进的数学变换。
12.2 数值正确性优先
科学计算中,允许重排浮点运算的优化可能改变舍入误差、迭代收敛路径和跨平台可复现性。
| 优化 | 可能影响 |
|---|---|
| 多累加器/树形归约 | 改变加法顺序和末位结果 |
-ffast-math | 允许重结合,并弱化 NaN、无穷和有符号零语义 |
| 融合乘加 FMA | 只舍入一次,结果可能与分离乘加不同 |
| SIMD/多线程归约 | 合并顺序可能随实现变化 |
对守恒律、残差判据和工程验证敏感的求解器,应先建立误差容限、回归算例和守恒检查,再启用激进优化。跑得快但悄悄改了物理结果,那不是高性能,是高速度制造事故。
12.3 与 GPU 和并行计算的联系
CPU 流水线强调指令级并行,GPU 则用大量线程隐藏长延迟;两者结构不同,但软件侧的共通问题包括:
- 依赖链越长,可并行调度的工作越少。
- 分支不规则会降低执行效率。
- 连续、规则的数据访问更容易利用存储系统。
- 需要用足够并行工作隐藏访存和运算延迟。
MPI/OpenMP/CUDA 解决的是更高层次的并行,不能自动消除单个核心或单个线程内部的低效依赖。
13. 性能观测工具
13.1 编译器报告
gcc -O3 -march=native -fopt-info-vec-optimized -fopt-info-vec-missed kernel.c
| 选项 | 作用 |
|---|---|
-O3 | 启用更积极的循环和过程优化 |
-march=native | 针对当前 CPU 启用可用指令集 |
-fopt-info-vec-optimized | 报告成功向量化的位置 |
-fopt-info-vec-missed | 解释未能向量化的原因 |
13.2 perf
perf stat -e cycles,instructions,branches,branch-misses ./solver
常用派生指标:
IPC = instructions / cycles
branch-miss-rate = branch-misses / branches
解释指标时要结合工作量:
- IPC 低可能表示依赖、Cache Miss 或分支停顿。
- IPC 高不代表算法快,程序可能只是执行了很多无用指令。
- SIMD 指令一次处理多个数据,指令数更少时 IPC 也未必更高。
- 比较优化前后时必须使用相同输入、线程数和数值正确性标准。
13.3 反汇编检查
objdump -d -Mintel ./solver
检查热点循环时关注:
- 是否出现
xmm/ymm/zmm向量指令。 - 循环中是否仍有函数调用。
- 是否存在频繁的条件跳转。
- 加载和存储是否连续、是否重复。
- 是否存在明显的单一累加器依赖链。
14. 常见坑
| 坑 | 正确理解 |
|---|---|
| 认为流水线让单条指令更快 | 流水线主要提高吞吐率,单条指令延迟未必下降 |
| 认为五级流水线一定加速 5 倍 | 阶段不均衡、寄存器开销和冒险都会降低收益 |
| 认为转发能解决所有数据冒险 | load/use 的数据产生得太晚,仍需停顿 |
| 混淆 Stall 和 Bubble | Stall 保持当前状态,Bubble 注入无副作用操作 |
认为 CPI = 1 是必然 | 分支失败、数据依赖和访存等待都会增加 CPI |
| 只看 IPC 判断程序快慢 | 还要看总周期、工作量、向量宽度和内存瓶颈 |
| 为消除分支计算两边所有结果 | 额外计算可能比分支预测失败更贵 |
盲目添加 restrict | 别名承诺不成立会导致未定义行为 |
对求解器直接启用 -ffast-math | 必须先验证收敛性、守恒性和可复现性 |
| 把 PIPE 等同于现代 CPU | PIPE 是教学模型,现代 CPU 还包含乱序、超标量和复杂预测器 |
15. 本章实验与复习路线
| 阶段 | 建议任务 | 检查目标 |
|---|---|---|
| ISA | 手工跟踪几条 Y86-64 指令 | 能写出每条指令读写的状态 |
| SEQ | 为一条新指令补充各阶段行为 | 理解数据通路与控制信号 |
| PIPE | 画出依赖指令的时序表 | 能判断转发、Stall 和 Bubble |
| Architecture Lab | 修改 HCL 并运行回归测试 | 保证功能正确并降低 CPE |
| 软件实验 | 对归约/Stencil 做展开与向量化 | 同时比较性能和数值误差 |
Architecture Lab 的典型三部分:
| 部分 | 任务 | 软件侧重点 |
|---|---|---|
| Part A | 编写 Y86-64 程序 | 熟悉 ISA、栈和过程调用 |
| Part B | 在 SEQ 中加入 iaddq | 理解一条新指令如何贯穿数据通路与控制逻辑 |
| Part C | 优化 PIPE 上的 ncopy | 用循环展开、减少分支和 ISA 扩展降低 CPE |
推荐复习顺序:
flowchart LR
A["先懂一条指令如何流过五阶段"] --> B["再分析 RAW 与 load/use"]
B --> C["再分析分支和 ret"]
C --> D["最后写 Stall/Bubble 控制"]
D --> E["映射到真实热点循环"]
本章最终应能回答:
- 流水线为什么提高吞吐率,却不保证降低单条指令延迟?
- 数据转发为何有效,load/use 又为何仍需停顿?
- 分支预测失败和
ret为什么会破坏连续取指? - Stall 与 Bubble 分别保持或改变哪些流水线状态?
- 一个科学计算循环受依赖、分支还是内存限制,应如何用证据区分?