ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

指令并行与流水线:从原理到代码优化的工程实践

指令并行与流水线:从原理到代码优化的工程实践 1. 从一条指令到一堆指令为什么需要指令并行很多人第一次接触“指令并行”和“流水线”这两个词是在计算机体系结构课上老师画了一堆方块说“取指、译码、执行、访存、写回”然后告诉你“这样就能让CPU更快”。但真正干活的人关心的不是这个概念本身而是我写的代码为什么跑得不够快为什么同样的算法别人优化完能快三倍为什么我的循环展开之后反而更慢了这些问题的答案全都藏在指令并行和流水线的细节里。先把话说清楚指令并行指的是让多条指令在同一时间段内重叠执行而不是老老实实一条接一条地串行跑。流水线是实现指令并行最经典、最基础的手段——把一条指令的执行拆成若干个阶段不同指令的不同阶段可以同时进行。你可以把它想象成工厂的装配线一辆车从底盘到喷漆到装轮子如果每个工位都等上一辆车完全装完再开始那效率低得可怕流水线的做法是第一辆车在装轮子的时候第二辆车已经在喷漆了第三辆车在装底盘了。这个类比虽然老套但它精准地说明了流水线的核心价值吞吐率。单条指令的执行时间延迟并没有缩短甚至因为流水线寄存器的引入还会略微增加但单位时间内完成的指令数量吞吐率可以提升到接近流水线级数倍。那这东西到底适合谁如果你是做嵌入式开发的你需要知道你的MCU流水线深度才能写出不触发流水线停顿的代码如果你是做高性能计算的你需要理解指令级并行才能把循环展开和软件流水做到位如果你是做编译器优化的流水线调度是你每天都要面对的问题哪怕你只是写应用层代码理解流水线也能帮你明白为什么分支预测失败会这么贵、为什么缓存命中率对性能影响这么大。我见过太多人写代码的时候完全不考虑底层执行模型结果就是明明算法复杂度一样性能差了好几倍还找不到原因。这篇文章就是要把指令并行和流水线这件事从原理到实操从参数计算到避坑经验全部拆开讲清楚。2. 流水线的基本结构与工作原理拆解2.1 经典五级流水线的阶段划分最经典的RISC流水线是五级取指IF、译码ID、执行EX、访存MEM、写回WB。每一级做一件明确的事IF根据PC从指令缓存中取出指令同时PC自增ID解码指令读取寄存器堆中的操作数EX执行算术逻辑运算或者计算访存地址MEM访问数据缓存读取或写入数据WB将结果写回寄存器堆理想情况下每个时钟周期都有一条新指令进入流水线每个时钟周期也有一条指令完成。稳态下流水线里同时有5条指令在不同阶段执行。但这里有一个关键问题每个阶段的时间必须平衡。如果EX阶段需要2ns而其他阶段只需要1ns那时钟周期就必须按最慢的阶段来定也就是2ns。这就意味着其他四个阶段每个周期都浪费了1ns。所以在实际设计中流水线的级数划分是一个非常讲究的工程问题——级数越多每级做的事情越少时钟频率可以越高但流水线寄存器的开销和冒险带来的惩罚也越大。注意流水线级数不是越多越好。Pentium 4的Prescott核心用了31级流水线结果分支预测失败的惩罚高达30多个周期实际性能反而被AMD的短流水线方案压制。这是一个经典的工程教训。2.2 流水线寄存器的角色与开销流水线之所以能工作靠的是在每一级之间插入流水线寄存器Pipeline Register。这些寄存器在时钟上升沿锁存上一级的输出供下一级使用。没有它们信号会在组合逻辑中乱窜根本没法同步。流水线寄存器的开销体现在三个方面第一建立时间和保持时间。寄存器的D端输入必须在时钟沿之前稳定一段时间建立时间并在时钟沿之后继续保持一段时间保持时间。这直接限制了时钟周期的最小值。第二寄存器的传播延迟。时钟沿到来后寄存器的Q端输出并不是立刻变化的有一个CLK-to-Q的延迟。这个延迟加上组合逻辑的延迟才是整个阶段的实际耗时。第三面积和功耗。每一级流水线寄存器都要占用芯片面积而且每个时钟周期都在翻转功耗不可忽视。在低功耗设计中有时候会故意减少流水线级数来降低功耗。我实际做过一个实验在一个FPGA上实现同一个处理器核五级流水线和三级流水线对比五级流水线的最高时钟频率大约高出40%但功耗增加了约25%而实际跑基准测试的IPC每周期指令数只高了不到10%。所以流水线深度和性能之间的关系远不是线性的。2.3 理想加速比与实际加速比的差距理论上k级流水线的加速比接近k。但实际中由于以下原因加速比会大打折扣流水线填充和排空程序开始和结束时流水线并没有满载冒险Hazard导致的停顿数据冒险、控制冒险、结构冒险流水线寄存器的额外延迟时钟偏移和抖动假设一个程序有N条指令k级流水线每条指令的理想执行时间是T那么非流水线总时间 N × k × T流水线总时间 (k N - 1) × T加速比 N × k / (k N - 1)当N远大于k时加速比趋近于k。但如果N和k接近加速比就会明显下降。比如N100k5加速比 500/104 ≈ 4.8而不是5。这个公式看起来简单但它告诉你一个重要的道理流水线适合处理大量指令的连续执行。如果你的代码里有大量分支跳转流水线经常被打断那加速比就会惨不忍睹。3. 流水线中的三类冒险与解决策略3.1 结构冒险硬件资源不够用怎么办结构冒险是指两条指令在同一周期争用同一个硬件资源。最典型的例子是如果只有一个存储器端口IF阶段要取指令MEM阶段要读数据两者就会冲突。解决办法有几种增加硬件资源比如指令缓存和数据缓存分开哈佛架构这样IF和MEM就不会争用同一个端口插入停顿让其中一条指令等一个周期但这会降低吞吐率资源复用调度通过编译器调度让需要访存的指令错开执行在实际的处理器设计中结构冒险通常通过增加硬件来解决因为停顿的代价太高。但在FPGA实现中BRAM端口数量有限结构冒险就是一个必须认真对待的问题。我在FPGA上实现RISC-V核的时候就因为指令和数据共用了一个BRAM端口导致每两条指令就要停顿一次性能直接腰斩。后来改成双端口BRAM问题才解决。3.2 数据冒险RAW、WAR、WAW的区分与处理数据冒险是指指令之间存在数据依赖关系导致后一条指令需要前一条指令的结果但前一条指令还没写回。数据冒险分三种RAWRead After Write后一条指令读的寄存器是前一条指令要写的。这是最常见的也是真正需要处理的。WARWrite After Read后一条指令写的寄存器是前一条指令要读的。在乱序执行中才需要考虑。WAWWrite After Write两条指令写同一个寄存器。同样在乱序执行中才需要考虑。在顺序流水线中WAR和WAW不会发生因为指令按顺序读寄存器和写寄存器。只有RAW是真正的问题。处理RAW的方法主要有三种第一种插入气泡Stall。检测到RAW冒险时让流水线停顿一个或多个周期直到前一条指令的结果可用。这是最简单的办法但性能损失大。第二种前递Forwarding/Bypassing。把EX阶段或MEM阶段的结果直接送到需要的地方而不是等写回。这是最常用的办法可以消除大部分RAW停顿。第三种编译器调度。编译器在生成代码时把没有依赖关系的指令插入到有依赖的指令之间避免流水线停顿。这需要编译器对流水线结构有精确的建模。前递路径的设计是流水线中最关键的细节之一。以经典的五级流水线为例EX冒险前一条指令在EX阶段产生结果后一条指令在EX阶段需要这个结果。需要从EX/MEM流水线寄存器前递到EX阶段的输入。MEM冒险前一条指令在MEM阶段产生结果比如load指令后一条指令在EX阶段需要这个结果。需要从MEM/WB流水线寄存器前递到EX阶段的输入同时插入一个周期的停顿。实操心得前递逻辑的优先级非常重要。如果EX阶段同时收到来自EX/MEM和MEM/WB的前递数据必须优先选择EX/MEM的数据因为它是最新的。这个优先级搞错会导致难以调试的数据错误。3.3 控制冒险分支预测与延迟槽的取舍控制冒险是指分支指令改变了PC导致流水线取到了错误的指令。处理控制冒险的方法有插入停顿检测到分支指令时停顿流水线直到分支结果确定。简单但代价高。分支预测预测分支是否跳转然后按预测结果取指。预测正确则无停顿预测错误则清空流水线。延迟槽在分支指令后面插入一条一定会执行的指令无论分支是否跳转。这是MIPS等架构的经典做法。提前计算分支结果把分支比较逻辑提前到ID阶段减少分支惩罚。分支预测又分静态预测和动态预测。静态预测简单粗暴比如“向后跳转预测为跳转向前跳转预测为不跳转”准确率大约70%-80%。动态预测使用分支历史表BHT和两位饱和计数器准确率可以到90%以上。更高级的Tournament预测器和TAGE预测器准确率可以到95%以上。但分支预测不是免费的。预测错误时流水线要清空已经取入的指令全部作废代价是流水线深度的周期数。31级流水线的Pentium 4预测错误惩罚超过30个周期这就是为什么它跑分支密集的代码时性能很差。延迟槽是另一种思路与其预测不如让编译器在分支指令后面放一条有用的指令。但延迟槽增加了编译器的负担而且当找不到合适指令时只能放NOP反而浪费了发射槽。现代处理器大多放弃了延迟槽转向动态分支预测。4. 从单发射到多发射指令级并行的进阶玩法4.1 超标量一个周期发射多条指令超标量Superscalar是指处理器在一个时钟周期内可以发射多条指令。比如双发射、四发射。这要求处理器有多个执行单元、多个端口寄存器堆、更复杂的冒险检测逻辑。超标量的核心挑战是指令调度。一个周期内取到了多条指令但它们的依赖关系可能不允许同时发射。处理器需要动态调度这些指令把没有依赖关系的指令配对发射。以双发射为例一个周期取两条指令如果它们之间没有RAW依赖而且需要的执行单元不冲突就可以同时发射。否则只能发射一条另一条等下一个周期。超标量的硬件开销很大。寄存器堆需要多个写端口和读端口前递网络变得非常复杂冒险检测逻辑的复杂度呈平方级增长。所以四发射以上的超标量处理器设计难度极高。4.2 乱序执行与寄存器重命名乱序执行Out-of-Order Execution是指处理器不按程序顺序执行指令而是根据操作数的就绪情况和执行单元的可用情况动态调度指令。这是现代高性能处理器的标配。乱序执行的核心机制是寄存器重命名。因为WAR和WAW冒险在乱序执行中会出现而这两种冒险其实不是真正的数据依赖只是寄存器名字冲突。通过重命名把逻辑寄存器映射到物理寄存器就可以消除WAR和WAW冒险。举个例子I1: ADD R1, R2, R3 ; R1 R2 R3 I2: SUB R4, R1, R5 ; R4 R1 - R5 I3: ADD R1, R6, R7 ; R1 R6 R7 I4: MUL R8, R1, R9 ; R8 R1 * R9I3和I1都写R1这是WAW冒险。I4读R1它应该读I3的结果而不是I1的结果。通过重命名I1写物理寄存器P1I3写物理寄存器P2I4读P2依赖关系就清晰了。乱序执行还需要**重排序缓冲ROB来保证指令按程序顺序提交以及保留站Reservation Station**来等待操作数就绪。这些结构的容量直接决定了处理器的指令窗口大小也就是能同时“看到”多少条指令。4.3 VLIW与EPIC把调度交给编译器VLIWVery Long Instruction Word是另一种思路与其用复杂的硬件动态调度不如让编译器静态调度把可以并行执行的指令打包成一条超长指令。VLIW的优点是硬件简单功耗低。缺点是编译器极其复杂而且二进制兼容性差——不同代际的处理器指令打包方式可能不同需要重新编译。Intel的Itanium是VLIW思路的代表但它市场表现不佳主要是因为编译器太难写实际性能没有达到预期。不过VLIW在DSP和嵌入式领域仍然有广泛应用因为那些场景的代码相对固定编译器可以做到很好的调度。5. 流水线实操从参数计算到代码优化5.1 流水线深度与时钟周期的计算实例假设我们要设计一个五级流水线每一级的组合逻辑延迟如下阶段组合逻辑延迟流水线寄存器延迟IF1.2 ns0.3 nsID0.9 ns0.3 nsEX1.5 ns0.3 nsMEM1.3 ns0.3 nsWB0.8 ns0.3 ns时钟周期必须满足最慢阶段的延迟加上寄存器延迟。最慢的是EX阶段1.5 ns 0.3 ns 1.8 ns。所以时钟周期最小为1.8 ns对应最高时钟频率约555 MHz。但这里有一个问题IF阶段只需要1.2 ns却要等1.8 ns浪费了0.6 ns。如果把EX阶段再拆成两级比如EX1和EX2每级0.75 ns那么最慢阶段变成MEM的1.3 ns 0.3 ns 1.6 ns时钟频率可以提升到625 MHz。但流水线变成了六级分支预测失败的惩罚从5个周期变成6个周期。这就是流水线设计中的经典权衡深度换频率但惩罚也增加。5.2 用代码示例演示数据冒险的影响下面这段C代码在五级流水线上执行时会有明显的RAW冒险int a 10; int b 20; int c a b; // 依赖a和b int d c * 2; // 依赖c int e d - a; // 依赖d和a编译成汇编后大致是这样的LW R1, a ; 加载a LW R2, b ; 加载b ADD R3, R1, R2 ; c a b SLL R4, R3, 1 ; d c * 2 SUB R5, R4, R1 ; e d - aADD指令需要R1和R2而R1和R2是前两条LW指令的结果。如果LW指令在MEM阶段才能拿到数据ADD在EX阶段就需要那就需要前递并且可能插入一个周期的停顿。如果编译器把代码重排一下LW R1, a LW R2, b LW R6, f ; 插入一条无关的加载指令 ADD R3, R1, R2 SLL R4, R3, 1 SUB R5, R4, R1这样ADD指令等待LW结果的时候LW R6已经在流水线里了停顿就被隐藏了。这就是编译器调度的价值。5.3 循环展开与软件流水实战循环展开是暴露指令级并行最直接的手段。看下面这个循环for (int i 0; i N; i) { c[i] a[i] b[i]; }每次迭代都有加载、加法、存储。如果直接编译每次迭代之间没有依赖但循环控制i、比较、跳转会占用发射槽。展开四次后for (int i 0; i N; i 4) { c[i] a[i] b[i]; c[i1] a[i1] b[i1]; c[i2] a[i2] b[i2]; c[i3] a[i3] b[i3]; }这样循环控制的开销被摊薄到四次迭代而且四条加法指令之间没有依赖可以并行执行。在四发射的处理器上理论吞吐率可以提升接近四倍。但循环展开不是万能的。展开太多会导致寄存器压力增大甚至溢出到栈上反而变慢。我实测下来展开因子在2到8之间比较合适具体要看寄存器数量和流水线深度。软件流水是更高级的技术把循环的不同迭代重叠执行让加载、计算、存储三个阶段在不同迭代中并行。这需要编译器做复杂的依赖分析和调度但效果非常好尤其是在数字信号处理和矩阵运算中。6. 常见问题与排查技巧实录6.1 流水线停顿的定位方法当你发现代码性能不如预期时第一步是确认是否存在流水线停顿。在Linux上可以用perf stat查看perf stat -e cycles,instructions,branches,branch-misses ./your_program关键指标是IPCInstructions Per Cycle。如果IPC远低于处理器的理论发射宽度说明存在停顿。比如四发射的处理器IPC只有1.2那肯定有问题。进一步定位可以用perf record和perf report看看热点函数在哪里。如果是分支密集的代码branch-misses会很高如果是数据依赖密集的代码IPC低但分支预测率正常。在嵌入式开发中如果没有perf可以用GPIO翻转加示波器来测量代码段的执行周期。虽然原始但非常有效。6.2 分支预测失败的典型场景与规避分支预测失败最常出现在以下场景数据相关的分支比如if (a[i] threshold)a[i]的值随机变化分支方向不可预测循环边界循环最后一次迭代时分支方向与之前不同容易预测失败间接跳转比如switch语句编译成的跳转表或者虚函数调用规避方法用条件传送替代分支x (a b) ? a : b;可以编译成CMOV指令避免分支循环展开减少循环控制分支的执行次数数据预排序如果分支依赖于数据值可以先把数据排序让分支方向变得可预测查表法用数组查找替代分支判断注意条件传送不是万能的。如果两个分支的计算量都很大条件传送会同时计算两个分支反而浪费。只有在分支体很小的时候才适合。6.3 多发射处理器的代码对齐与调度建议在多发射处理器上指令对齐很重要。很多处理器要求指令包按特定边界对齐比如每4条指令一组组内可以并行发射组间有依赖检查。如果指令没有对齐可能会浪费发射槽。编译器通常会自动处理对齐但手写汇编时需要注意。另外把有依赖关系的指令放在不同的发射组里可以避免组内冲突。还有一个经验尽量让长延迟指令如load尽早发射。load指令的结果通常需要几个周期才能用如果把它放在依赖它的指令前面几个位置就可以隐藏延迟。编译器一般会做这个调度但如果编译器不够聪明手动调整指令顺序会有明显效果。6.4 常见问题速查表问题现象可能原因排查方法解决思路IPC远低于发射宽度数据冒险导致停顿perf stat看IPCperf record看热点循环展开、指令重排、前递优化分支预测失败率高数据相关分支、间接跳转perf stat看branch-misses条件传送、查表、数据预排序展开后性能反而下降寄存器溢出、缓存压力看汇编是否有spill看缓存命中率减小展开因子、优化数据布局多线程性能不升反降共享资源争用、伪共享perf stat看cache-misses数据对齐、减少共享、调整线程数流水线频繁清空分支密集、异常频繁看分支预测率和异常计数重构代码、减少异常、优化分支7. 我踩过的坑与实操建议第一个坑以为流水线越深越好。早期我做FPGA处理器设计时为了追求高频率把流水线拆到了七级。结果跑实际程序时分支预测失败的惩罚太大性能还不如五级流水线。后来我学乖了先跑基准测试再决定流水线深度。第二个坑忽视前递网络的优先级。有一次调试一个五级流水线发现某些情况下计算结果不对。查了很久才发现是前递逻辑的优先级搞反了EX/MEM的数据被MEM/WB的数据覆盖了。这个bug非常隐蔽因为大部分情况下两个前递源的数据是一样的只有在特定指令序列下才会暴露。第三个坑循环展开过度。有一次为了优化一个矩阵乘法把循环展开了16次结果寄存器不够用编译器把变量溢出到栈上性能反而下降了30%。后来改成展开4次性能提升了2倍多。这个教训是展开因子要匹配处理器的寄存器数量和流水线深度不是越大越好。第四个坑忽略指令缓存的影响。循环展开会增加代码体积如果超出了指令缓存容量取指就会变慢。我见过一个案例展开后代码从2KB变成8KB刚好超过了4KB的指令缓存性能直接腰斩。实操心得优化流水线性能第一步永远是测量不是猜测。用perf、用示波器、用计数器拿到真实数据再动手。我见过太多人凭感觉优化结果越优化越慢。还有一个建议如果你在做嵌入式开发一定要看芯片手册里的流水线章节。不同厂商的流水线设计差异很大有的有分支延迟槽有的有load延迟槽有的有指令对齐要求。这些细节不搞清楚写出来的代码性能会差很多。最后分享一个技巧在关键循环里把最耗时的指令放在循环体的开头把循环控制放在结尾。这样即使有流水线停顿停顿也发生在循环控制阶段不会阻塞关键计算。这个技巧在DSP编程中特别有用我实测下来某些循环能提升15%到20%的性能。
返回列表