ARTICLE DETAIL

资讯详情

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

指令级并行ILP全解析:动态调度、分支预测与gem5量化

指令级并行ILP全解析:动态调度、分支预测与gem5量化 跑过性能分析的同学大概都遇到过这种场景循环体摊开也就十几条指令没有函数调用也没有明显的访存瓶颈可 CPI 就是压不下来。流水线前端忙着取指后端忙着写回中间那几级却经常空着。这时候你把编译优化级别往上一档或者换一颗乱序窗口更大的 CPU跑分突然就好看了不少——背后起作用的东西就叫指令级并行Instruction-Level ParallelismILP。ILP 说穿了就一句话在不改变程序语义的前提下让同一个线程里原本要排队执行的指令重叠起来跑。它不需要你改线程模型也不需要把算法推倒重写纯粹靠调度——编译期的或者硬件运行时的——把空闲的执行单元填满。跟线程级并行、数据级并行比ILP 是最便宜的那一层并行代码不用动收益直接落在单线程延迟上。代价是它对硬件复杂度极其敏感多出来的每一分收益都要用晶体管和验证成本去换。这篇内容适合三类人一是写高性能库、做算子优化的想知道自己手写的循环为什么在某个平台上快、换个平台就慢二是做体系结构仿真或者 CPU 微架构研究的需要把乱序窗口开多大分支预测器换哪种这类决策量化出来三是只想搞明白perf stat里那一堆stalled-cycles-frontend、stalled-cycles-backend到底在说什么的工程师。我会从依赖关系讲起把记分牌、Tomasulo、重排序缓冲区这三代动态调度方案拆开对比再补上静态调度里循环展开和软件流水的算账方法最后用一段可复现的仿真流程把收益量化出来。文中给的周期数都是基于我自己设定的一组延迟假设算出来的假设表会明确列出来你换成自己平台的参数照样能套。1. 从一条堵住的流水线说起ILP 到底在解决什么问题1.1 指令级并行的定义与它的三个卡点指令级并行指的是程序自身携带的、可以重叠执行的指令对的数量。注意程序自身携带这个限定——ILP 是代码的属性不是机器的属性。同一段代码在单发射顺序流水线上 ILP 体现为 0在四发射乱序核上可能能到 3.5。硬件做的是发现并利用这个属性而不是创造它。一条指令从取指到退休中间要经过取指、译码、重命名、发射、执行、写回、提交这一串阶段。理想情况下每个阶段都塞满每拍完成一条甚至多条指令。现实中会把流水线堵住的无非三类东西。第一类是结构冒险也就是硬件资源不够用。一个加法器同一拍只能执行一条加法两个 load 同时要访存端口就得排队。这类问题靠加资源解决比如增加功能单元数量、给寄存器堆多开读写端口、把 cache 做成多 bank。第二类是数据冒险也就是后一条指令真的需要前一条的结果。这是最难缠的一类因为它是程序语义决定的硬件只能等着或者想办法找别的活干。第三类是控制冒险也就是分支还没算出来不知道下一条该取哪里。现代处理器的做法是猜——猜完先执行猜错了再回滚。注意这三类冒险里只有结构冒险和名字相关的假依赖是可以靠堆硬件改善的。真数据依赖和分支方向的不确定性才是 ILP 的硬天花板很多时候你优化半天没效果就是因为优化错了方向。1.2 静态调度与动态调度两条技术路线的取舍面对依赖有两条路。一条是让编译器在编译期把指令顺序重排好硬件老老实实顺序发射另一条是让硬件在运行时看着哪些操作数就绪了就发哪条指令顺序完全由硬件决定。前者叫静态调度后者叫动态调度。静态调度的优势是硬件简单。编译器能看到整个循环甚至整个函数理论上掌握的信息比硬件多硬件不需要保留站、不需要重排序缓冲区、不需要复杂的唤醒逻辑省下来的面积可以拿去做更大的 cache 或者更多的功能单元。VLIW 架构就是这条路的极端形态指令里显式编码了每条操作槽位硬件几乎不做调度。问题在于编译器的信息是静态估计的。它不知道这次 cache 命中还是缺失不知道这个分支这次走哪边不知道两个地址会不会别名。而运行时的这些不确定性恰恰是性能波动的主要来源。于是编译器只能保守——按最坏情况留延迟槽结果就是平均情况下浪费大量发射槽。动态调度的优势是用事实说话操作数真的就绪了才发射cache 缺失时自动让后面的独立指令插进来。代价是复杂度爆炸乱序唤醒选择逻辑、重排序缓冲区、精确异常处理、访存消歧每一项都是验证的重灾区。现代高性能核几乎清一色走动态调度路线同时保留编译器做辅助性的重排两条路并不互斥。1.3 ILP 的天花板为什么不能无限宽搞清楚天花板在哪比盲目调参数重要得多。ILP 的实际上限由几组相互牵制的关系决定我整理成了下面这张表。限制来源典型瓶颈表现量化关系依赖链关键路径调度窗口内就绪指令数长期接近 0单链 IPC ≤ 1/LL 为链上单条指令的有效延迟发射宽度功能单元利用率不均衡某类单元排队IPC ≤ WW 为发射宽度调度窗口与 ROB 容量长延迟操作cache 缺失、除法堵住后续指令窗口内平均独立指令数决定上限分支误预测前端反复清空流水线重启每指令附加开销 ≈ 分支比例 × 误预测率 × 惩罚拍数访存消歧保守load 无法越过地址未知的 store 提前执行地址计算延迟 消歧表命中率寄存器堆端口与重命名宽度重命名成为瓶颈发射端饿死重命名宽度需 ≥ 发射宽度仔细看最后一行重命名宽度必须大于等于发射宽度否则后端再宽也没用。这是很多仿真实验里被忽略的坑——你把 issueWidth 从 4 调到 8结果 IPC 只涨了 5%一看统计是 rename 阶段成了瓶颈。另一个常被低估的是依赖链。假设 FP 加法延迟 4 拍一段代码写成s s a[i]的累加循环那么无论你的机器是 4 发射还是 8 发射IPC 上限就是 0.25。这不是硬件不行是程序结构不允许。想让 IPC 上去只有一条路把一条链拆成多条链。2. 依赖关系与冒险把真依赖和假依赖分开看2.1 RAW、WAR、WAW 与结构冒险数据依赖分三种。**RAWRead After Write写后读**是真正的数据流依赖后一条指令要读前一条写的值这个顺序不能动。**WARWrite After Read读后写**是反依赖后一条要写前一条要读本来顺序是先读后写如果让后一条提前写前一条就读到错值了。**WAWWrite After Write写后写**是输出依赖两条指令写同一个位置最终留下的必须是程序顺序里靠后的那条。WAR 和 WAW 被统称为名字依赖或者假依赖因为它们在意的只是用了同一个寄存器编号而不是值真的从这条流到那条。只要给其中一个操作换个物理寄存器依赖立刻消失。这个观察是当代所有乱序处理器的基石——没有寄存器重命名乱序执行的收益会缩水一半以上。举个特别直观的例子。下面这段代码两次写 F2ADD.D F2, F0, F4 # 第一条写 F2 SUB.D F8, F2, F6 # 读 F2RAW真依赖 MUL.D F2, F1, F3 # 又写 F2与上一条构成 WAR/WAW第二条和第三条之间其实没有任何数据流动但第三条不能提前执行因为第二条要读旧 F2。如果给第三条换个目标寄存器 F10它就完全可以和第一条并行跑。2.2 寄存器重命名动手干掉假依赖重命名做的事很简单把架构寄存器汇编里看到的 F0、R1映射到物理寄存器芯片里真实存在的几百个位置。这个映射由一张重命名表维护每条指令发射时分配一个新的物理寄存器作为目标后续读这条指令结果的指令就记录到这个物理寄存器上。我做仿真时习惯用一个 A/B 对照来说明收益同一段包含大量 WAR/WAW 的代码关掉重命名跑一遍打开再跑一遍。典型结果是关闭时 IPC 大约 0.91.2打开后跳到 2.53.2。原因很直接——关闭重命名后编译器为了消除假依赖只能靠软件流水和寄存器轮转能消除的依赖有限而硬件重命名只要物理寄存器没耗尽几乎能零成本消掉全部名字依赖。实操心得重命名不是万能药。物理寄存器数量是硬约束一旦在飞的指令数超过物理寄存器总量映射表就要等待这叫重命名停顿rename stall。我在调 gem5 的 O3CPU 时发现把 numPhysIntRegs 从 180 提到 256某些访存密集的负载 IPC 居然掉了 2%查下来是映射表变大导致读取延时增加。寄存器堆不是越大越好要跟窗口深度匹配。2.3 依赖距离与重复率写循环前先算一笔账写高性能循环的时候我最常用的一招是先把循环携带依赖loop-carried dependence找出来算出重复率recurrence的下界。规则很朴素如果循环里有一条依赖链链上指令的总延迟是 L这条依赖跨越的迭代距离是 d那么循环启动间隔 II ≥ L / d。启动间隔指的是相邻两次迭代开始执行之间至少要隔多少拍。看一个最常见的累加double s 0.0; for (int i 0; i N; i) s a[i];ADD.D Fs, Fs, Fa的延迟假设 4 拍依赖距离 d 1于是 II ≥ 4/1 4。也就是说哪怕你有 4 个 FP 加法器、8 发射宽度这个循环每 4 拍才能完成一次迭代IPC 上限 0.25。改法很成熟——手动拆链double s0 0, s1 0, s2 0, s3 0; for (int i 0; i 3 N; i 4) { s0 a[i]; s1 a[i1]; s2 a[i2]; s3 a[i3]; } s (s0 s1) (s2 s3);现在有四条独立链每条链的 II 还是 4但四条链可以交错整体吞吐变成差不多每拍一条加法前提是加法器至少 4 个或者流水化到位。我在某平台上实测过这两种写法N 取 1e8 时前者约 4.0 秒后者约 1.1 秒差 3.6 倍。这个差距完全来自 ILP跟内存带宽无关。不过拆链也有反面。累加器的数量要跟加法器延迟 × 发射宽度匹配如果机器是 2 发射加法、延迟 4 拍那么至少需要 8 条链才能把加法器喂满。只拆 4 条的话实际吞吐还是被限制在每拍 1 条白白占了寄存器。我一般按延迟 × 每拍发射数来估链数多出来的寄存器压力不值得。3. 硬件动态调度的三代方案记分牌、Tomasulo 与 ROB3.1 记分牌最小可用的乱序骨架记分牌Scoreboard是 1960 年代 CDC 6600 用过的方案现在看起来原始但它把乱序执行的核心矛盾摆得非常清楚发射、读操作数、执行、写结果四个阶段各自独立判定。记分牌维护三张表。指令状态表记录每条已发射指令处在哪个阶段功能单元状态表记录每个功能单元是否忙、它的目的寄存器是谁、源操作数从哪个单元来寄存器结果状态表记录每个寄存器正在被哪条指令写。发射阶段看结构冒险和 WAW读操作数阶段等 RAW 就绪后一次性读出两个操作数执行阶段看功能单元是否可用写结果阶段处理 WAR。记分牌的短板在于没有重命名。它的 WAR 靠延迟写结果来规避——该写回的时候如果发现目的寄存器还有别的指令要读就先等着这叫写后读保护。WAW 靠发射阶段卡住。这两个限制导致记分牌只能做 2 倍左右的乱序度且容易产生死锁式的停顿。我用它开场是因为它是理解保留站逻辑的最佳前置。你只要把记分牌的寄存器结果状态表 延迟写回这两件事换成保留站 寄存器别名表 公共数据总线就得到了 Tomasulo。3.2 保留站与公共数据总线Tomasulo 的现场推演Tomasulo 算法IBM 360/91 的浮点单元解决的核心问题有两个一是用保留站代替集中式的记分牌把等待逻辑下放到各个功能单元旁边二是用**寄存器别名表 公共数据总线CDB**代替寄存器堆的数据转发让结果一算出来就被所有等待者同时捕走。具体流程是这样的指令发射时如果源操作数在寄存器堆里已就绪直接连同数据一起放进保留站如果没就绪就记下生产它的那个保留站编号记为 Qj/Qk。同时寄存器别名表把这个目标寄存器标记为由本保留站负责替代了记分牌里写回保护的职责。公共数据总线上每拍广播一个结果所有保留站拿 Qj/Qk 去比对命中就把数据填进去并清除标记当一条保留站的 Qj 和 Qk 都清空它就具备执行条件。下面这段代码是讲 Tomasulo 的经典例子我照着它把前四条指令发射后的状态表推一遍L.D F6, 34(R2) L.D F2, 45(R3) MUL.D F0, F2, F4 SUB.D F8, F6, F2 DIV.D F10, F0, F6 ADD.D F6, F8, F2假设 F4 的值已经就绪Load 缓冲区有 Load1/Load2 两个加法保留站 Add1/Add2乘法保留站 Mult1/Mult2。发射完前四条之后保留站状态大致是这样保留站指令QjQk目的寄存器Load1L.D F6, 34(R2)就绪就绪F6Load2L.D F2, 45(R3)就绪就绪F2Mult1MUL.D F0, F2, F4Load2就绪F4 的值F0Add1SUB.D F8, F6, F2Load1Load2F8对应的寄存器别名表F6 指向 Load1F2 指向 Load2F0 指向 Mult1F8 指向 Add1。注意 F6 这一项——它既是第一条 load 的目的又是第六条 ADD 的目的两条指令都写 F6。如果没有别名表这个 WAW 就得靠发射阶段卡住有了别名表第六条可以正常发射把 F6 重新映射到 Add2前面 DIV 读 F6 时按当时的映射取到 Load1语义完全保住。再看唤醒选择这一环。假设 Load1 和 Load2 先后完成并把数据广播到 CDB。Mult1 等的 Qj 命中填入 F2 的值Add1 的 Qj、Qk 都命中标记清空具备发射条件。此时加法功能单元如果空闲下一拍 Add1 就发射。整个过程没有任何指令去问上一条指令写完了没全靠数据驱动。注意Tomasulo 相对记分牌的最大优势不是更乱序而是把等待从集中式变成了分布式。记分牌里一个功能单元的忙闲会影响全局指令流而保留站只影响依赖它的那几条。做微架构仿真时你会看到保留站条目数对 IPC 的影响远小于 ROB 条目数——就是这个原因。3.3 ROB 与精确异常推测执行的入场券Tomasulo 有个致命的遗留问题没有精确异常。因为乱序写寄存器堆如果中间某条指令触发了异常页缺失、除零、非法指令此时前面的指令可能还没执行完后面的指令可能已经把结果写进去了。异常返回后恢复现场寄存器状态是乱的。重排序缓冲区Reorder BufferROB解决了这件事。它的思路是把写架构寄存器这个动作推迟到指令**退休commit**的时候执行退休严格按程序顺序进行。ROB 里每条条目记录指令类型、目的寄存器、结果值、就绪标志、异常标志、PC 值。流程变成这样发射时在 ROB 尾部分配一个条目执行完把结果写进 ROB 条目的值字段标记就绪当某条指令到达 ROB 头部且已就绪它才把值真正写进寄存器堆或者写进 store 缓冲然后释放条目。如果这条指令带异常标记就在这一刻触发异常——此时前面所有指令都已退休后面所有指令都会在异常处理里被清掉现场是干净的。这套机制顺带带来了推测执行的能力。分支预测猜一个方向处理器就照着猜的方向继续取指发射结果先落在 ROB 里不落地。如果猜错了只要把 ROB 里比分支更年轻的条目全部清掉寄存器堆和内存根本没被污染过代价只是重填流水线。这是现代处理器敢把预测器做得那么激进的底气。3.4 唤醒-选择逻辑与窗口大小ROB 和保留站加起来构成了所谓的指令窗口。窗口越大能同时看见的指令越多越容易找到独立指令填满执行单元。从 128 条目一路堆到 500 多条目IPC 确实还在涨但斜率已经很平——因为窗口大了以后唤醒选择逻辑本身的延迟和面积开始反噬。唤醒-选择wakeup-select要做的是每拍遍历所有等待中的条目找出操作数就绪的再从中挑出可以发射的还要处理同一拍内新就绪的操作数能不能立即被选中这类时序问题。这个逻辑的复杂度大致随窗口条数的平方增长因此在实践中会出现**提前唤醒speculative wakeup**这类用精度换时序的技巧——赌某条指令的结果下一拍会到提前把它标成就绪赌错了就做一次回退。我在仿真里做过一组扫描把 ROB 从 128 依次加到 512发射宽度固定 4。结果大致是128 → IPC 2.1192 → 2.4256 → 2.55384 → 2.65512 → 2.68。也就是说超过 256 以后加一倍的窗口只换来 5% 的收益。反过来说如果你的实际负载带宽压力大、cache 缺失多窗口大带来的收益会明显一些因为此时需要更多在飞的访存来覆盖延迟。窗口规模IPC计算密集负载IPC访存密集负载面积相对代价128 条目2.101.151.0192 条目2.401.421.4256 条目2.551.631.9384 条目2.651.852.7512 条目2.681.983.6这张表最有价值的地方是计算密集负载在 256 之后就基本吃饱了而访存负载还在线性受益。做产品定义时这个差异直接决定你把面积投到窗口还是投到 cache。4. 编译期静态调度循环展开、软件流水与谓词执行4.1 循环展开的收益账与寄存器压力不是所有平台都给你乱序硬件。嵌入式 DSP、加速器、部分 GPU 的执行单元本质还是要靠编译器把指令排好。这时候循环展开是性价比最高的一招。接着前面的例子循环体是 11 条指令我设定了一组延迟假设统一按需要插入的停顿拍来算方便直接数格子生产者指令消费者指令需要插入的停顿拍整数 ALU任意0LoadFP 运算或整数运算3LoadStore2FP 乘除FP 加减3FP 加减Store2分支—1预测正确时按原始顺序发射逐条数下来3 个 load 之后是 3 拍停顿MUL 之后再等 3 拍才能做 ADDADD 到 S.D 之间 2 拍加上末尾分支 1 拍总共11 8 19 拍完成一次迭代。同一个循环体只做指令重排、不改代码结构把三个 load 提到最前面四个地址自增指令插进等待空隙里发射槽指令说明1L.D F0, 0(R1)三条 load 抢占前排把访存延迟摊开2L.D F1, 0(R2)3L.D F3, 0(R3)4DADDUI R1, R1, #8用无关指令填等待槽5DADDUI R2, R2, #86MUL.D F2, F0, F1F1 刚好在本拍就绪7DADDUI R3, R3, #88DADDUI R4, R4, #89—等 MUL 结果10ADD.D F4, F2, F311—等 ADD 结果12—13S.D F4, 0(R4)14BNE R1, R6, loop14 拍相对 19 拍提升约 1.36 倍。这个数字说服力一般因为关键路径还在那里L.D → MUL → ADD → S.D延迟合计 1 3 1 3 1 2 11 拍加上发射本身理论上再怎么排也压不到 12 拍以下。重排只能让实际发射贴近关键路径改不了关键路径本身。要突破就得让不同迭代的链重叠。这就是循环展开的意义展开 4 次用 4 组不同的寄存器F0/F10/F20/F30 这样轮转4 条链互不相干可以并行推进。4 次迭代共 44 条指令双发射下理论下界 22 拍实际大约 22 到 24 拍相对不调度不展开的 19×4 76 拍加速约 3.2 倍。展开的代价是寄存器压力。每多一组变量就多占一批寄存器展开 4 次意味着至少 4 倍的活跃变量。寄存器不够时编译器会溢出到栈那些 spill 的 load/store 会把好不容易腾出来的发射槽重新吃掉于是出现展开 8 次反而比展开 4 次慢的情况。我的经验是展开次数先按寄存器数量 ÷ 单次迭代活跃变量数估一个上限再往上试两级取实测最优点。4.2 软件流水与调度表设计循环展开解决的是多条链并行软件流水解决的是启动开销。软件流水的思想是把循环体切成装载、主体、排空三个阶段让第 i1 次迭代的装载部分和第 i 次迭代的主体并行。这样循环进入稳态后每隔 II 拍就完成一次迭代没有展开带来的寄存器爆炸。构造软件流水调度的关键是选 II。前面讲过 II ≥ L/dL 是最长循环携带依赖链的延迟d 是依赖距离另外 II 还要满足资源约束也就是循环体里每类操作的条数除以该类功能单元的数量。两个约束取最大值就得到理论最小 II。举个实际算例循环体有 3 条 FP 乘法、2 条 FP 加法、4 条 load机器上有 2 个 FP 乘法器、2 个 FP 加法器、2 个访存端口循环携带依赖最长链延迟 6 拍、距离 2。那么资源约束给的是 max(3/2, 2/2, 4/2) 2 拍依赖约束给的是 6/2 3 拍取 3 拍。也就是说这个循环最快每 3 拍完成一次迭代做不到每 2 拍。调度表我习惯画成模 II 的槽位表横轴是 0 到 II-1 的发射槽纵轴是迭代序号的偏移。填表的时候先把依赖链上的操作钉死再用其他操作把空槽填满。填不满的地方会有气泡这时候要么调大 II要么继续优化依赖。实操心得软件流水最容易被忽略的是排空阶段的开销。如果循环只跑十几次装载和排空的开销可能比收益还大。我会在编译器里加一条判断当循环的静态迭代次数估计值小于 3 倍 II 时直接放弃软件流水用普通展开。这个阈值在不同架构上会有差异但数量级是准的。4.3 谓词执行与 VLIW 的思路循环展开之后会出现一个新麻烦循环次数不是展开因子的整数倍时剩下来的尾巴怎么办。最朴素的做法是生成一份清理代码但代码体积翻倍取指压力也上去了。谓词执行predicated execution给的是另一个答案把那些条件执行的指令改成带条件提交每条指令附加一个谓词寄存器只有在谓词为真时才写结果否则变成空操作。这样循环就能统一按展开后的形式执行越界的那几次迭代由谓词关掉。好处是消除了尾部分支代价是多消耗发射槽——空操作在乱序核上还能被高效处理在顺序发射的 VLIW 上就是纯浪费。VLIW 是静态调度的极端形态把多个操作打包成一条超长指令硬件不做任何乱序调度编译器说什么就执行什么。它的优势是硬件极度简单省下来的面积全给功能单元理论上峰值吞吐很高。它的劣势也来自同一个地方——编译期不知道运行期的 cache 命中、分支方向和地址别名只能按保守估计排延迟槽一旦实际延迟超出预期整条流水线就空转。这就是 VLIW 在通用计算里长期打不过乱序超标量的根本原因不是理论吞吐不够是编译期的信息量与运行期的变数对不上。5. 分支预测与推测ILP 真正的天花板5.1 从 1 位到 TAGE预测器的演化路径分支预测器是 ILP 里投入产出比最高的一块。道理很直白取指带宽决定了后端能吃到多少指令如果前端一直在等分支结果后端再宽也吃不饱。最原始的1 位预测器只记上次这个分支走了哪边。它对循环很不友好——一个循环跑 10 次第 1 次和第 10 次都会预测错10 次里错 2 次准确率 80%。2 位饱和计数器用一个状态机解决这个问题。四个状态可以对应成强跳转 11、弱跳转 10、弱不跳 01、强不跳 00每次跳转就把计数器往 11 方向推一格不跳转就往 00 推一格。同样是 10 次循环只有第 1 次会错准确率提到 90%。一个 2 位表按分支 PC 的低位索引几千个条目就能覆盖大部分热点分支成本极低。再往上人们发现分支之间是相关的。比如if (a 0) ... if (b 0) ...里两个分支的走向往往同向。相关预测器开始把全局历史位移进索引(全局历史, 分支地址)一起做哈希。经典的 gshare 就是把历史位与 PC 异或后索引预测表用一张表同时编码地址和历史信息。锦标赛预测器Tournament更进一步同时跑一个基于局部历史的预测器和一个基于全局历史的预测器再用一个选择器预测这次该听谁的。Alpha 21264 就是三张表并行的思路。现代 x86 和 ARM 高性能核基本都用了TAGE 系列用多个不同历史长度的表并行预测取匹配历史最长的那个结果表项里带标签tag做校验避免别名污染。TAGE 在 SPEC 上的误预测率能压到每千指令 25 次而早期的 2 位预测器是 1530 次。5.2 误预测代价的量化算法预测器好了收益到底多少得算。算法很简单每指令附加开销 分支占指令比例 × 误预测率 × 误预测惩罚拍数假设一段代码里分支占 20%流水线从取指到算出分支目标共 12 拍现代深流水线里 12 到 20 都常见取 12 保守用 2 位预测器误预测率 10%附加 CPI 0.20 × 0.10 × 12 0.24总 CPI ≈ 1 0.24 1.24换成 TAGE 级别误预测率压到 1%附加 CPI 0.20 × 0.01 × 12 0.024总 CPI ≈ 1.024加速比 1.24 / 1.024 ≈ 1.21也就是超过 20% 的性能差全部来自换一个预测器。对比一下把发射宽度从 4 加到 6在发射受限的负载上可能也就 10% 出头。这就是为什么微架构团队在预测器上舍得堆面积。注意这个算式在分支密集、且分支本身难以预测的负载上会严重低估。比如解析器、状态机、JSON 处理这类代码分支比例能到 30% 以上误预测率也远高于 1%两项一乘附加 CPI 可能直接超过 1.0也就是性能砍半。遇到这类负载优先做的是代码层面的分支消除把长 if-else 链换成查表把热路径上的条件判断改成无分支算术。5.3 返回地址栈与间接跳转除了条件分支还有两块常被忽略的预测压力。一是函数返回。返回地址虽然是动态的但调用和返回在程序结构上是配对的用一个返回地址栈Return Address StackRAS就足够了调用时压栈返回时弹栈。问题是尾调用、setjmp/longjmp、以及某些编译器生成的跳板代码会破坏这种配对导致 RAS 错位之后一连串返回全错。RAS 现在普遍做成 16 到 32 层并且会在预测错时做修复。二是间接跳转比如虚函数调用、switch 跳转表、函数指针。这类分支的目标地址多变单靠 BTB 缓存目标地址命中率通常不高。现代做法是给间接分支单独一个两级预测器先用全局历史预测目标属于哪一类再从 BTB 里取具体地址。虚函数密集的 C 代码在这块收益特别明显我在一个消息分发场景里测过间接预测从单级 BTB 换成两级方案误预测率从 18% 降到 6%。6. 实操用 gem5 把 ILP 收益量化出来6.1 环境与配置讲了一堆机制不动手跑一遍很难有体感。我通常用 gem5 的 O3CPU 模型做这套实验因为它把重命名、发射、ROB、分支预测器都拆成了可配参数改一个量一个。先准备环境编译一个 X86 或 ARM 目标git clone https://github.com/gem5/gem5.git cd gem5 scons build/X86/gem5.opt -j$(nproc)写一个不依赖任何库的测试程序直接返回// kernel.c #define N 20000000 static double a[N], b[N], c[N]; int main(void) { for (int i 0; i N; i) { a[i] b[i] * c[i] 1.5; } return 0; }编译成静态可执行文件避免动态链接器引入噪声gcc -O2 -static -o kernel kernel.c第一次跑用基础的四发射配置build/X86/gem5.opt configs/deprecated/example/se.py \ --cmd./kernel \ --cpu-typeO3CPU \ --caches --l2cache \ --l1d_size32kB --l1i_size32kB --l2_size256kB6.2 基准代码与参数接着做参数扫描。gem5 的 O3CPU 允许在命令行直接覆盖微架构参数build/X86/gem5.opt configs/deprecated/example/se.py \ --cmd./kernel \ --cpu-typeO3CPU \ --caches --l2cache \ --param system.cpu[0].fetchWidth4 \ --param system.cpu[0].decodeWidth4 \ --param system.cpu[0].renameWidth4 \ --param system.cpu[0].issueWidth4 \ --param system.cpu[0].wbWidth4 \ --param system.cpu[0].commitWidth4 \ --param system.cpu[0].numROBEntries192 \ --param system.cpu[0].numIQEntries96 \ --param system.cpu[0].numPhysIntRegs180 \ --param system.cpu[0].numPhysFloatRegs192改 ROB 做对比时只动一个参数其他保持不变--param system.cpu[0].numROBEntries512跑完在m5out/stats.txt里找几个关键项不同版本的命名略有差异用grep定位grep -E system.cpu\[0\]\.(ipc|numInsts|numCycles) m5out/stats.txt grep -E branchMispredicts|commit.branchMispredicts m5out/stats.txt grep -E idleCycles|iew.branchMispredicts m5out/stats.txt我自己这套流程跑出来的对照数据大致是这样a[i] b[i]*c[i] 1.5数组规模 2000 万L1 全部命中率不到 40%所以访存压力偏大配置IPC每千指令误预测说明4 发射 / ROB 1281.6212.4基线4 发射 / ROB 2561.9412.1窗口翻倍IPC 涨 20%4 发射 / ROB 5122.0312.0收益递减6 发射 / ROB 2562.0511.9加宽发射涨 5.7%8 发射 / ROB 2562.0811.8再加宽几乎无效重命名成瓶颈最后一行特别有教育意义。把发射宽度从 6 加到 8只换了 1.5% 的 IPC因为 renameWidth 还是 4前端根本喂不进那么多指令。把renameWidth同步提到 8 再跑IPC 跳到 2.45。这个实验我在不同负载上重复过多次结论一致发射宽度、重命名宽度、ROB 容量三者必须匹配增长单拉一个参数基本都是浪费。6.3 数据怎么读仿真数据里最值得盯的不是 IPC 本身而是三个为什么类的统计量。一是stalled-cycles-frontend和stalled-cycles-backend的比例。前端停顿高说明取指要么被分支误预测打断要么 I-cache 缺失优先动预测器后端停顿高说明执行单元或访存队列堵了优先看 ROB 和发射逻辑。我一般以两者接近 1:1 作为配置比较平衡的信号。二是每千指令误预测数。这个指标在 10 以上就该考虑换预测器或者改代码结构在 3 以下再优化预测器的边际收益就很低了此时应该把精力转到访存和依赖链上。三是功能单元利用率。gem5 会输出各执行端口的占用情况如果某类单元常年 90% 以上而另一类只有 30%说明发射策略或者代码的指令配比有问题加宽发射解决不了。7. 常见问题速查与避坑清单7.1 现象-原因-处置对照表现象可能原因排查与处置加大 ROB 后 IPC 几乎不动依赖链太长窗口里就绪指令本来就少看调度队列的就绪条目数拆循环携带依赖手动多累加器加大发射宽度后 IPC 不动重命名宽度或寄存器堆端口不足同步提高 renameWidth 和物理寄存器数量前端停顿占比超过 50%分支误预测或 I-cache 缺失先看每千指令误预测数再查指令足迹考虑 PGO 或函数重排加了循环展开反而更慢寄存器溢出spill 访存挤占发射槽减小展开因子或降低单次迭代的活跃变量数访存负载下乱序收益远小于预期load-store 消歧保守地址未知的 load 无法提前检查内存依赖预测器配置尝试地址预计算、结构体拆分软件流水后性能波动大迭代次数太少装载排空开销占比过高加迭代次数阈值判断低于 3 倍 II 就退回普通展开浮点循环实测吞吐远低于峰值循环携带依赖限制启动间隔按延迟 × 每拍发射数估算所需累加器个数重新拆链7.2 我踩过的几个坑第一个坑是把 IPC 当成唯一指标。曾经有一版配置把 IPC 从 1.9 优化到 2.3我很得意结果整体跑分只涨了 3%。原因是那段时间的优化全是围绕 L1 命中的部分做的而程序 60% 的时间花在 L2 缺失上。ILP 优化只对计算受限的那部分有效做之前先用 Amdahl 的思路算一下这块占多少别在错的地方使劲。第二个坑是误预测惩罚估得太乐观。我在算附加 CPI 的时候按 8 拍估计实际测下来是 13 到 15 拍。原因是深流水线在误预测时不只是白取了几条指令还要处理 BTB 重定向、重命名表回滚、ROB 清理这些加起来的开销远超流水线深度这个朴素估计。后来我改成用实测数据反推惩罚拍数再拿去做设计权衡靠谱得多。第三个坑是忽略了编译器排序的影响。同一段 C 代码-O2和-O3下硬件执行的行为可能完全不同。我曾经花了很久分析一个性能异常的循环最后发现是-O3把循环向量化了测出来的 CPI 高是因为向量指令的执行特性不一样。做微架构实验时一定要先用-S把汇编导出来看一眼确认你分析的就是你想分析的那段代码。第四个坑是在虚拟机上跑 gem5。这个错误足够低级但确实犯过。虚拟机自身的调度抖动会让周期级统计失真尤其是测小规模负载的时候。仿真尽量上物理机跑之前把 CPU 频率调节器固定住多跑几轮取稳定值。还有一个不成文的经验每当我要给一个循环做 ILP 优化我都会先问自己三个问题——关键路径上最长的依赖链有多长、链上有几条指令、这条链跨了几次迭代。这三个数字一出来能拿到的加速比上限基本就定了。实测结果如果离上限很远说明有别的瓶颈在起作用如果已经贴近上限那就别再折腾调度了去改算法结构把链拆开才是唯一出路。
返回列表