CPU乱序执行全解析:从Tomasulo算法到现代处理器微架构
一部电影里主角明明先把结局拍完再把开头补上最后剪出来的片子照样能拿奥斯卡。现代 CPU 里的指令执行本质上就是这么一回事。乱序执行Out-of-Order Execution这个词很多搞开发的同学听过但真让你说清楚它到底怎么“乱”、为什么“乱”了还能保证结果不出错怕是能讲利索的人不多。花了些时间把 x86、ARM 和 RISC-V 几个主流架构的乱序执行实现捋了一遍又对照着看了不少处理器核的微架构文档今天把整个体系拆开聊聊。这篇文章适合写底层代码的、做性能优化的、以及单纯对 CPU 工作原理好奇的读者——读完你至少能明白三件事CPU 为什么非要“倒着干活”它是怎么做到既快又准的以及这套机制给软件性能优化带来了哪些实实在在的影响。1. 乱序执行到底在解决什么问题1.1 从流水线说起指令是怎么被“排队”处理的要理解乱序执行得先看它的对立面——顺序执行。早期的 CPU 处理指令很老实取一条、执行一条、再取一条。后来的 CPU 引入了流水线Pipeline把一条指令的执行拆成多个阶段比如经典的五级流水线取指Fetch、译码Decode、执行Execute、访存Memory、写回Write Back。这么做的目的是让不同指令的不同阶段可以重叠处理就像工厂流水线一样第一道工序在处理零件 A 时第二道工序已经在处理零件 B 了理论上每个时钟周期都能完成一条指令。但流水线有个非常头疼的问题冒险Hazard。冒险分三种——结构冒险、数据冒险、控制冒险。结构冒险是硬件资源不够用比如执行单元只有一个两条指令同时要用就冲突了控制冒险是分支跳转导致流水线不知道下一条该取谁数据冒险则是今天的主角指的是一条指令依赖前面指令的计算结果。数据冒险的经典例子就是a b c; d a * 2;第二条指令要用 a但 a 要等第一条指令算完才有。如果严格按照顺序执行第二条指令必须在流水线里“干等”这叫流水线停顿Stall。后来的 CPU 想了个办法叫前递Forwarding把第一条指令的结果直接送到第二条指令的输入端不用等写回寄存器文件。但这个办法只能解决相邻指令的问题如果两条指令隔得稍微远点或者依赖链很长数据冒险依然是流水线性能的头号杀手。1.2 为什么“按顺序等”会浪费大量宝贵的时钟周期我们来算一笔账。假设一条加法指令延迟是 4 个周期乘法是 8 个周期访问内存是 200 个周期如果没命中缓存的话。当流水线遇到一条需要等待这些结果的指令时后续的指令全都堵住了。早期顺序执行的 CPU 面对这种情况只能“刹车”。让我们设想一个典型代码片段float sum 0; for (int i 0; i N; i) { sum data[i] * weight[i]; }这条循环里的sum data[i] * weight[i]实际上是个串行依赖链每次累加都要等上一次累加完成。如果按顺序执行这个循环的性能就被这条长长的依赖链锁死了。即使 CPU 主频再高、流水线再深也只能干瞪眼。乱序执行的出现就是为了打破这种僵局。它的核心思路很叛逆既然等在前面的指令会阻塞那我先看看后面有没有不依赖前面结果的指令先拿来执行不就行了这个“不按常理出牌”的做法让处理器能够填满那些原本空转的时钟周期。1.3 乱序执行的真实起点从 IBM 360/91 到 Tomasulo 算法乱序执行并不是近几年才有的黑科技。早在 1966 年IBM 360/91 大型机上就用了这套思路背后的算法是 Robert Tomasulo 提出的。这套算法的核心思想用今天的话说就是不依赖顺序而是“数据就绪了就执行”。它把指令的等待过程变成了一种数据流驱动的调度指令不再被简单地排队等待而是先进入一个“等待区”只有等它需要的所有操作数都准备好了才被发送到对应的执行单元。Tomasulo 算法在当年是划时代的它解决了两个核心痛点一是寄存器数据相关性导致的等待二是寄存器的假竞争。后来几乎所有乱序执行处理器包括现代的 x86、ARM 和 RISC-V 高端核心在原理上都是 Tomasulo 算法的变体和改进。理解了这一点你再看任何一款乱序 CPU 的微架构框图基本都能找到寄存器重命名、保留站、重排序缓冲这几大件。2. 乱序执行架构的核心组件拆解2.1 寄存器重命名消除“假相关”乱序执行面临的第一个难题其实是寄存器之间的“虚假冲突”。之所以叫“假”冲突是因为这些冲突并非真正依赖数据而是因为寄存器的名字被复用了。看这段代码add r1, r2, r3 # r1 r2 r3 sub r4, r1, r5 # r4 r1 - r5 mul r1, r6, r7 # r1 r6 * r7 and r8, r1, r9 # r8 r1 r9这里sub依赖第一条add的结果这是真依赖。但第三条mul也把结果写到 r1这就在 r1 这个名字上和第一条指令产生了冲突——这种现象叫写后写WAWWrite After Write。第四条and读 r1它到底该读哪次的 r1如果严格按顺序它应该读第三条mul的结果但如果乱序执行mul和add的执行顺序无法保证这就是灾难。解决办法就是寄存器重命名Register Renaming。CPU 内部有远比架构寄存器多得多的物理寄存器比如 x86 在架构上只有 16 个通用寄存器但 Intel 和 AMD 的物理寄存器堆通常有 200 个以上。当指令被译码后硬件会把架构寄存器地址如 r1映射到某个空闲的物理寄存器上。上面的代码经过重命名后可能是add p40, p12, p13 # r1 映射到 p40 sub p41, p40, p15 # r1 映射到 p41读 p40上一次 add 的结果 mul p42, p16, p17 # r1 映射到 p42 and p43, p42, p19 # r1 映射到 p43读 p42上一次 mul 的结果这样一来四条指令可以没有任何顺序约束地并行执行add、sub、mul、and之间原本因 r1 名字复用造成的假依赖全部消失只剩下sub对add和and对mul之间真正的数据依赖。寄存器重命名解决的一个非常反直觉的问题哪怕两条指令要写同一个寄存器只要物理寄存器够用它们也能并行执行完全不用等待对方。这也是为什么物理寄存器堆的大小直接决定了一颗乱序 CPU 核心能够“乱”到什么程度。2.2 保留站指令的“等待室”指令经过译码和寄存器重命名之后会被送入保留站Reservation Station。保留站本质上是一个分布式调度器——每个执行单元如整数运算单元、浮点运算单元、访存单元前面都挂着一个或多个保留站条目等待操作数齐备后立刻发射Issue执行。保留站的工作逻辑就是 Tomasulo 算法的精髓每一条指令在进入保留站的那一刻就会记录下它需要的源操作数寄存器。如果源操作数已经在寄存器堆里准备好了就直接取值如果还没准备好就记录下“需要等哪条指令的结果”然后进入监视状态。当那条被依赖的指令执行完毕结果会通过一条专用的结果总线Common Data BusCDB广播给所有保留站。保留站里等待的指令看到自己需要的值到了马上把它锁存下来然后检查自己是否所有操作数都就绪——全部就绪就向执行单元申请发射。这个设计的好处在哪它把“等待”从流水线的全局阻塞变成了每个执行单元的局部等待。指令 A 在等待一个慢速的浮点除法时指令 B 和 C 完全可以同时在其他执行单元上跑起来谁也不耽误谁。2.3 重排序缓冲ROB乱序执行有序提交看到这里你可能会问指令都乱序执行了结果怎么保证和顺序执行一样这就是重排序缓冲Reorder BufferROB的职责。ROB 是一个环形缓冲它的作用是为每条指令登记一个“顺序号”按程序顺序记录指令的原始排列。指令在乱序执行完成后并不立刻把结果写到最终的寄存器堆里而是先暂存在 ROB 中。只有当 ROB 中排在它前面的所有指令都已执行完成并提交Commit这条指令才能按顺序“退役”Retire把结果正式写入寄存器或内存。ROB 的设计解决了一个非常棘手的问题精确异常Precise Exception。如果乱序执行的指令在早期阶段出错了或者触发了一个中断CPU 必须能够恢复到指令流中某个准确的边界状态——也就是说处理器必须能说清楚“执行到哪条指令为止哪些结果生效了哪些结果还没生效”。有了 ROB这个边界就非常清晰所有已提交的指令结果生效还没提交的指令结果作废。这个能力对操作系统来说至关重要因为进程切换、信号处理、调试器的单步执行都依赖这种“精确的指令边界”能力。很多人以为“乱序执行”是随便执行、不守规矩其实它的“乱”只在执行阶段提交阶段严格有序。乱序是为了快有序是为了“对”。为了更直观地理解顺序执行和乱序执行的区别这里梳理一个对照表对比维度顺序执行In-Order乱序执行Out-of-Order指令发射严格按程序顺序操作数就绪即可发射执行结果立即写回先暂存 ROB按顺序提交数据冒险靠停顿/前递靠保留站动态调度寄存器复用冲突天然无冲突靠寄存器重命名消除异常处理天然精确靠 ROB 保证精确硬件复杂度低高每周期指令数低高功耗开销低高典型代表ARM Cortex-A53、RISC-V 低功耗核Intel Core 系列、ARM Cortex-A77、Apple M 系列3. 数据依赖、内存顺序和“正确性”到底怎么保证3.1 数据冒险的三种类型与乱序执行的应对策略要理解乱序执行如何保证正确性必须把数据冒险的几种类型讲透。教科书上把数据冒险分为三类写后读RAWRead After Write、写后写WAWWrite After Write、读后写WARWrite After Read。RAW 是真数据依赖第二条指令要读第一条指令刚写出的值。乱序执行通过保留站的动态调度来等待同时配合前递网络把执行单元算出的结果直接送到需要它的地方不用等它绕一圈回到寄存器堆。WAW 和 WAR 属于名字相关Name Dependence本质上是因为寄存器名字复用造成的假冲突靠寄存器重命名直接消除。有意思的是x86 架构由于寄存器数量少只有 16 个通用寄存器代码密度高但名字冲突严重所以重命名的重要性比 RISC 架构更突出。这也是为什么现代 x86 处理器动辄配备 200 多个物理寄存器而 ARM 与 RISC-V 因为架构寄存器较多32 个物理寄存器数相对少一些也能获得不错的效果。3.2 访存指令没有想象中那么简单内存消歧如果说寄存器层面的乱序已经有难度那访存指令的乱序更是难上加难。为什么因为寄存器有唯一的名字编译器在编译期间就能解析出依赖关系。但内存地址是在程序运行时才能算出来的两条访存指令会不会访问同一块地址在指令将要执行时根本无法百分之百确定。举个例子store r1, [r5] # 把 r1 写入内存地址 r5 load r6, [r7] # 从内存地址 r7 读数据到 r6这两条指令如果乱序load先执行了但随后发现store要写的地址和load读的地址是同一个——那load就读到了旧数据结果就错了。反过来如果store先执行了而load的地址和它不同顺序反而没问题。为了解决这个难题现代处理器的做法是访存指令也走乱序但会通过一个内存消歧机制动态检查。硬件会记录哪些 store 指令尚未提交并跟踪它们的地址。处理器允许load在store之前执行但会建立一个“存储转发”Store-to-Load Forwarding机制如果后来的load发现自己的地址和某个未提交的store相同就优先把那个store的数据转发给它。如果load已经乱序执行了结果发现和前方某个store地址冲突怎么办处理器的答案是把从load开始的后续指令全部废弃重新执行。这种机制叫“流水线冲刷”Pipeline Flush代价非常大但能保证正确性。为了减少这种冲刷硬件还会做一个保守策略在地址不明的情况下默认不把load乱序到store之前。这里有一个概念值得单独拿出来说就是内存排序模型Memory Ordering Model。x86 使用 TSOTotal Store Order全存储排序模型store 不会乱序提交load 也不会随便越过 store 前移而 ARM 和 RISC-V 使用更宽松的模型允许更激进的重排但代价是程序员必须显式使用内存屏障指令来同步。这也是为什么做并发编程时不同架构下的内存屏障语义差别巨大。3.3 精确异常乱序执行“正确性”的底线我们只能终止程序了。一个干净利落的办法是记录一条“断点指令”的精确位置。在这个位置之前的指令已经完整执行完毕在这个位置之后的指令一条都不能执行。如果没有 ROB乱序执行可能在断点位置之后已经提前执行了十几条指令它们的结果有些写回了寄存器有些改了内存。要把这些副作用全部撤掉几乎是不可能的。ROB 的存在让这个难题变得简单处理器只需把 ROB 里所有尚未提交的指令全部丢弃然后把断点之前已提交的指令作为现场就可以准确地构造出异常发生时的架构状态。操作系统拿到这个状态就可以实现进程切换、响应中断、抛出异常一切井然有序。这也是为什么 ARM 和 RISC-V 在讲“异常返回”时都会强调返回地址和处理器状态必须精确对应。没有乱序执行这件事天然成立有了乱序执行ROB 就是保证它成立的基石。4. 乱序执行的工程实现与软硬件协同4.1 前端取指、译码和指令窗口乱序执行不只是后端执行单元的事整个前端也要做出相应的设计。前端的核心职责是尽可能多地获取指令供后端乱序消费。现代处理器的前端一般包括分支预测器、指令缓存I-Cache、指令译码器、以及一个巨大的指令队列Instruction Queue。分支预测在这里的作用尤其关键。乱序执行的前提是后端要有足够的“乱序余地”如果前端取指速度跟不上后端再厉害也吃不饱。预测失败一次流水线从头再来代价是二三十个时钟周期没了。这就是为什么现代 CPU 的分支预测器越做越复杂局部历史、全局历史、循环预测器、神经网络预测器……一切努力都是为了减少分支预测失败的次数让前端持续稳定地向乱序执行后端输送指令。4.2 后端从译码到执行的物理实现细节后端是乱序执行的核心。指令从译码器出来经过重命名进入保留站和 ROB。这里有一个关键参数重排序缓冲区的大小。ROB 越大能容纳的未提交指令越多发现远处指令级并行ILP的机会就越大。Intel Core 系列的 ROB 大小通常是 300~500 项左右Apple M 系列芯片的 ROB 更大因此它能同时追踪和提交更长的指令窗口。保留站的数量和分类也值得关注。有的处理器采用集中式保留站所有执行单元共享有的采用分布式保留站每个端口一组。现代主流微架构多用分布式保留站因为它可以降低每个端口的竞争延迟。执行结果回写有两种方式一种是结果总线广播CDB所有保留站在时钟周期内监听总线另一种是更精细的“精确点对点转发”通过旁路网络直接把结果送到等待方。前者简单但总线竞争激烈后者速度快但硬件复杂度高。各厂商在这两种方案之间做了不同的折中这也是为什么同样标称 4GHz 的处理器IPC每时钟周期指令数差异可能很大的原因之一。4.3 硬件与软件的协同编译器如何配合乱序执行很多做性能优化的同学以为乱序执行是硬件单方面的事跟软件没关系。实际上乱序执行的效果在很大程度上依赖于软件编译器产出的指令流质量。编译器在做指令调度时会尽量让指令之间的依赖链变短把独立的计算穿插排列以充分利用乱序执行窗口的并行能力。经典的优化手法包括循环展开Loop Unrolling把循环体复制多份减少循环控制的开销同时暴露出更多可并行的指令。软件流水Software Pipelining将循环体内不同迭代的计算交叠在一起填满不同执行单元的空闲时间。减少长依赖链将一系列串行浮点计算重组为树形结构降低关键路径长度。值得注意的是乱序执行的能力是有限度的。它只能消除名字相关假相关不能消除真正的数据依赖。如果一条指令链上每步都必须等上一步的结果比如一个递归的 FP 求和那无论 CPU 的乱序窗口多大也无法打破这个顺序约束。4.4 一个关键参数指令级并行ILP的上限业界衡量乱序执行效果的核心指标是指令级并行ILPInstruction-Level Parallelism。ILP 的物理上限取决于三条因素程序本身的依赖关系这是硬约束任何处理器都无法突破。处理器提供的硬件资源ROB 大小、保留站数量、执行单元数量、内存带宽。前端供给能力分支预测准确率、指令缓存命中率、译码带宽。如果你的程序本身 ILP 很高比如图像像素处理、矩阵计算乱序执行能轻松把执行单元的利用率拉满。但如果程序是一条严重的串行依赖链乱序执行也只能束手无策。这也是为什么在做性能优化时有人会告诉你“如果你发现 IPC 很低先看看是不是关键路径上的数据依赖太长”。5. 常见问题与性能调优的实操心得5.1 乱序执行常见误区速查表在日常交流中关于乱序执行的误解相当多。我整理了常被问到的几个问题做成了一个速查表常见误解实际情况乱序执行 指令随便执行只是执行阶段乱序提交阶段严格有序乱序执行会导致计算错误结果与顺序执行完全一致这是设计底线乱序执行 多线程并行是在单核内挖掘指令级并行和线程并行是两码事乱序执行只在高端 CPU 才有现代中端 ARM 核心几乎都支持只是窗口大小不同乱序执行一定能提升性能只有在 ILP 较高时有效串行依赖链上提升有限乱序执行完全按乱序写回通过 ROB 保证写回/提交的有序性5.2 用性能计数器验证乱序执行的存在与效果如果你想亲眼验证乱序执行是否在工作最直接的方式是查看处理器的性能计数器Performance Counter。在 Linux 下可以用perf工具观察到以下关键指标stalled-cycles-frontend前端停顿周期数。如果这个值偏高说明分支预测失败或取指带宽不足后端在“饿肚子”。stalled-cycles-backend后端停顿周期数。如果这个值偏高说明执行资源不够或数据依赖链太长保留站没有足够的可用指令去填充执行单元。instructions和cycles两者相除得到 IPC。IPC 越接近处理器的上限如 4 或 6说明 ILP 挖掘得越充分。举个简单的例子你在一个数组上做累加时for (int i 0; i N; i) { sum a[i]; }如果你开-O2编译编译器通常会把这段改成多个部分和并行累加以打破依赖链。但如果你用手写汇编规规矩矩地一条条累加就会发现 IPC 低得可怜——因为乱序执行窗口再大也突破不了这条串行的依赖链。用perf stat对比这两个版本你会看到 IPC 的显著差异。这就是“乱序执行不是万能神药”的最直观证据。5.3 对代码优化的几点实际建议基于乱序执行的运行原理我总结了一些实实在在的优化心得第一优先打破长依赖链。同等条件下把浮点求和拆成四个独立的累加器在循环结束时再相加性能提升往往立竿见影。原因很简单四个独立的依赖链可以让乱序执行窗口同时在四条链上并行推进。第二注意分支密集的代码。每次分支预测失败都要冲刷流水线把乱序执行积累的优势瞬间归零。如果你的代码里有难以预测的分支尽量把它改写成无分支的算术运算或者使用查表法。第三留意访存模式。乱序执行可以掩盖一部分内存延迟但掩盖不了缓存缺失。如果大量数据不在缓存中load 指令会在很长一段时间内无法获得操作数后面的指令也会被阻塞。所以数据布局要尽量满足缓存行对齐、顺序访问等特性。第四理解内存屏障带来的性能损失。因为乱序执行允许 load 和某些内存操作重排在多核并发编程时不得不使用内存屏障如std::atomic_thread_fence。每一次屏障都会限制重排等于告诉乱序执行引擎“这里的灵活性你没有了”。所以能用宽松原子操作如memory_order_relaxed就尽量别用最严格的全屏障语义。5.4 小结乱序执行给性能世界的启示在 IO 密集型的数据库系统里或网络服务里CPU 经常处于一种状态前端拼命地取指令后端有些执行单元在等待内存返回的数据而另外一些执行单元却没有活干。乱序执行的价值就是在这种“部分繁忙、部分等待”的混沌状态中尽可能把执行单元的利用率推高。它带来的一层更深启示是硬件与软件的边界其实一直在流动。当 CPU 的乱序窗口足够深、重排序缓冲足够大时某些代码层面的“优化”可能根本没有意义但当程序的关键路径是一条串行依赖链时硬件又显得无能为力。理解这个边界你就能更理性地判断你的程序性能瓶颈到底是在 CPU 的前端、后端还是在程序本身的依赖结构里。踩过调试器和性能分析工具的很多坑之后我个人最大的体会是深入研究 CPU 微架构的知识不是要让自己变成那种只会背手册、讲术语的人而是要在做性能优化决策时能一眼看穿那些看似合理的优化方案背后到底是不是真能跑得更快。真正理解了乱序执行的工作原理很多之前玄学般的性能问题其实都能算得清清楚楚。