【硬核体系结构与编译器技术】硬件流水线的秘密与ILP的极限拉扯
这份笔记源自于(99+ 封私信) 【硬核体系结构与编译器技术01】1. 从冯诺依曼到乱序执行:硬件流水线的秘密 - 知乎,目前是通过 AI 初步整理,加上作者的一些个人理解和修改整理得到的。主要脉络如下:
- 简要介绍冯诺依曼体系结构下, CPU 执行指令的过程。
- 初步引出指令执行效率的优化方法:流水线(限于硬件层)。
- 深入分析流水线的性能瓶颈原因:数据依赖。
- 针对 CPU,在硬件层面上如何解决该问题?
- 针对 NPU,初步引出后续又是如何解决的?
概述
现代微架构提升单核性能的基石是流水线,但指令间的逻辑相关会导致流水线卡壳(冒险)。为了榨取指令级并行(ILP),CPU用极其昂贵的硬件代价(旁路转发、乱序执行)来强行消除停顿;而在追求极致算力密度的NPU中,这套动态硬件机制被彻底抛弃,复杂的调度矛盾被转移给了编译器。
一切的开端:流水线解决了什么?
1. 冯·诺依曼的串行困境
经典的冯·诺依曼架构执行指令是绝对串行的:取指令(IF) → 译码(ID) → 执行(EX) → 访存(MEM) → 写回(WB)。
- 痛点:部件极度闲置。执行(EX)时,取指令(IF)和访存(MEM)单元都在空转。
- 指标:CPI(Cycles Per Instruction,每条指令平均时钟周期数)= 最长指令周期数(串行下CPI=5,极度低效)。
2. 流水线的诞生
将串行工位拆分为流水线工位,让不同指令的不同阶段在同一时刻重叠执行。
原文是通过一个餐厅重厨师做菜的例子进行介绍:
与其让大厨一个人包揽全干,不如把厨房拆分成五个工位:接单员(IF)、切菜工(ID)、炒菜大厨(EX)、拿盘小工(MEM)、端菜服务员(WB)。我们来看看引入流水线以后发生了什么:
时间 (分钟) 接单员 (IF) 切菜工 (ID) 炒菜大厨 (EX) 拿盘小工 (MEM) 端菜员 (WB) 第 1 分钟 菜 1 - - - - 第 2 分钟 菜 2 菜 1 - - - 第 3 分钟 菜 3 菜 2 菜 1 - - 第 4 分钟 菜 4 菜 3 菜 2 菜 1 - 第 5 分钟 菜 5 菜 4 菜 3 菜 2 菜 1 (出餐!) 第 6 分钟 菜 6 菜 5 菜 4 菜 3 菜 2 (出餐!) 第 7 分钟 菜 7 菜 6 菜 5 菜 4 菜 3 (出餐!)
- 性能飞跃:虽然单条指令的延迟不变,但整体吞吐率激增。理想状态下,CPI 逼近 1,IPC(Instructions Per Cycle,每周期完成指令数,即CPU吞吐率)逼近 1。
- 类比:5级流水线厨房,预热完毕后每分钟出1道菜,10道菜从原本50周期降至14周期(10+4)。
- 旁注:流水线不是越深越好。Intel Pentium 4 的31级超深流水线导致分支惩罚爆炸、功耗失控,同频效率低下,最终得不偿失。证明深度流水线收益存在极限。
流水线卡壳:数据相关的幽灵
这里需要简单补充介绍下流水线中数据相关的描述:
流水线中有三种数据相关:写后读(RAW)相关、读后写(WAR)相关、写后写(WAW)相关。
- 读后写(WAR):第一条指令是读操作,第二条指令是写操作。
- 写后读(RAW):第一条指令是写操作,第二条指令是读操作。
- 写后写(WAW):第一条指令是写操作,第二条指令也是写操作。
这里的Write,针对的对象是寄存器。例子如下:
LDA R1, A ;M(A)->R1,M(A)是存储单元 ADD R2, R1 ;(R2)+(R1)->R2分析:第一条指令向R1中写入了新值,第二条指令读取了R1中的值,先写后读,写后读(RAW)相关。
ADD R3, R4 ;(R3)+(R4)->R3 MUL R4, R5 ;(R4)*(R5)->R4分析:第一条指令读取了R4中的内容,第二条指令向R4中写入了新值,先读后写,读后写(WAR)相关。
LDA R6, B ;M(B)->R6,M(B)是存储单元 MUL R6, R7 ;(R6)*(R7)->R6分析:第一条指令向R6中写入了新值,第二条指令也向R6中写入了新值,先写后写,写后写(WAW)相关。
流水线的理想状态建立在“指令完全独立”的假设上,但现实中指令间存在逻辑依赖。
- 相关:程序逻辑上的依赖。
- 冒险:逻辑相关在物理流水线上引发的碰撞冲突。
1. 第一层:纸老虎 —— 名称相关(假相关)
指令间不需要彼此的数据,仅仅是因为“撞名”(使用了同一个寄存器/盘子)而冲突。
- WAW(写后写 - 输出相关):两条指令都要写入同一个寄存器。
- WAR(读后写 - 反相关):指令1读,指令2写同一个寄存器,需保证读在前。
- 解法:寄存器重命名。换一个盘子即可!
- 静态:编译器通过 SSA(静态单赋值)修改目标寄存器。
- 动态:CPU硬件将逻辑寄存器映射到物理寄存器(RAT表),使用空闲的物理寄存器(如p寄存器),冲突瞬间消失。
2. 第二层:真困局 —— 数据相关(真相关)
RAW(读后写 - 真数据相关):指令2必须使用指令1的计算结果。
这是绝对的值依赖,无法通过换名字消除。指令2必须等指令1算完,这会导致流水线产生气泡停顿。
指令 1:ADD R1, R2, R3 ; 菜 1:把 R2 和 R3 的食材炒到一起,装进盘子 R1 指令 2:SUB R4, R1, R5 ; 菜 2:把盘子 R1 里的菜,和 R5 一起再加工,装进 R4时钟周期 取指 (IF) 译码 (ID) 执行 (EX) 访存 (MEM) 写回 (WB) C1 指令 1 (ADD) - - - - C2 指令 2 (SUB) 指令 1 (ADD) - - - C3 - 指令 2 (SUB) 指令 1 (ADD) - - C4 - - ⚠️ 停顿 指令 1 (ADD) - C5 - - - ⚠️ 停顿 指令 1 (ADD) (R1终于写回!) C6 - - 指令 2 (SUB) - - 注:另一种冒险是控制冒险,由
if-else分支引起,现代CPU靠分支预测器盲猜解决。
硬件的暴力美学:对抗 RAW 冒险
面对无法消除的 RAW 相关,CPU硬件工程师选择砸晶体管“逆天改命”。
第一招:旁路转发 / 前馈
痛点:指令2傻等指令1把结果写回寄存器堆(WB阶段),再从寄存器堆读出来。
解法:在ALU输出端与输入端之间拉专线。指令1在EX阶段算完,直接把结果递给指令2的EX阶段,跳过WB过程。
指令 1:ADD R1, R2, R3 ; 菜 1:把 R2 和 R3 的食材炒到一起,装进盘子 R1 指令 2:SUB R4, R1, R5 ; 菜 2:把盘子 R1 里的菜,和 R5 一起再加工,装进 R4时钟周期 取指 (IF) 译码 (ID) 执行 (EX) 访存 (MEM) 写回 (WB) C1 指令 1 (ADD) - - - - C2 指令 2 (SUB) 指令 1 (ADD) - - - C3 - 指令 2 (SUB) 指令 1 (ADD) - - C4 - - 指令 2 (SUB) (指令2没有等) 指令 1 (ADD) - C5 - - - 指令 2 (SUB) 指令 1 (ADD) (R1写回!) C6 - - - - 指令 2 (SUB) 效果:消灭了大部分基础计算带来的气泡。
第二招:终极武器 —— 乱序执行
- 痛点:如果指令1是Cache Miss访存(耗时200周期),旁路转发也救不了,指令2依然得死等。
- 解法:既然指令2要等,CPU就越过它,看看后面的指令3、4能不能先执行(拍黄瓜先端上去)。
- 硬件代价:构建极其庞大的动态调度帝国:
- 保留站:让等食材的指令靠边站。
- 重排序缓冲(ROB):内部乱序执行,但对外保证按序提交结果。
- 记分板:监控每个寄存器和执行单元的状态。
- 绝望的“ILP 墙”:乱序执行核心的“唤醒与选择逻辑”复杂度呈 爆炸增长,但实际程序的IPC提升往往不到20%。CPU花了80%的硅片和功耗,只为抠出一点点ILP。
NPU 的硬件困局:让编译器来吧!
在AI时代,面对海量的矩阵乘法,CPU的动态调度玩法彻底失效:太贵,太占地方。 CPU芯片上真正的计算单元(ALU)只占极小面积,大部分被控制逻辑吃掉。而NPU的唯一宿命是算力密度(MAC/SFU)。
NPU 的三大抛弃:
- 抛弃旁路网络:NPU是海量MAC构成的脉动阵列,动态旁路布线面积会瞬间爆炸。
- 抛弃乱序执行与记分板:SRAM读写冲突的动态检测电路代价无法承受。
- 抛弃1D指令流的灵活调度:NPU处理的是2D/高维张量块,无法做微观乱序。
复杂度的转移:从硬件到编译器
既然NPU硬件是个“只会算数的愣头青”,那流水线卡壳谁来解决?
答案是:编译器。
- AI程序的数据流动是高度确定的,没有复杂的指针和乱序分支。
- 既然硬件“动态瞎猜”太费晶体管,AI编译器就能在运行前精确掌握每个数据的生命周期,直接在软件层面手工编排出一条严丝合缝的流水线。引出下篇核心:编译器的降维打击——软件流水线。