ARTICLE DETAIL

资讯详情

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

指令级并行实战:从依赖链、寄存器重命名到perf性能分析

指令级并行实战:从依赖链、寄存器重命名到perf性能分析 两份功能完全等价的求和代码跑在同一台机器上一份比另一份快出三倍多——这种事我遇到过不止一次。第一次碰到的时候我怀疑编译器抽风后来拿性能计数器一测问题根本不在编译器而在指令级并行Instruction-Level ParallelismILP上一份代码把加法排成了一条长长的链后一条必须等前一条算完才能开工另一份把这条链拆成了四条互不相干的短链CPU 的执行单元立刻就忙起来了。指令级并行讲的是一段程序里本来就可以重叠执行的指令之间到底存在多少潜在并行空间。注意潜在两个字它跟程序里能不能开线程、能不能上 SIMD 完全是两码事指的是最底层那一层——单核、单线程、顺序语义不变的前提下硬件和编译器还能从指令流里榨出多少重叠执行的机会。搞性能优化的人多少都得了解这一层因为它决定了很多看起来等价的写法为什么差出好几倍做编译器和芯片微架构的人更绕不开它这是整个乱序执行体系的理论地基。这篇文章我想按自己的理解路径从依赖关系讲到动态调度再讲推测执行和编译器能做的部分最后落到我自己怎么用perf把 ILP 瓶颈量出来、怎么改代码。1. 先把 ILP 这个词拆开并行度到底藏在哪几条指令之间1.1 从一段等价的求和代码说起先摆一个最能说明问题的例子。下面两段 C 代码算的是同一个东西——数组求和结果完全一致都是O(n)的循环指令条数也差不多。// 版本 A单一累加器 float sum_a(const float *a, int n) { float s 0.0f; for (int i 0; i n; i) s a[i]; return s; } // 版本 B四路部分和 float sum_b(const float *a, int n) { float s0 0, s1 0, s2 0, s3 0; int i 0; for (; i 3 n; i 4) { s0 a[i]; s1 a[i1]; s2 a[i2]; s3 a[i3]; } for (; i n; i) s0 a[i]; return (s0 s1) (s2 s3); }版本 A 里s a[i]这一次的加法必须等上一次迭代的加法算完因为用的是同一个s。这条链有多长n 次加法首尾相接串成一条直线。版本 B 里有四条链s0到s3各自独立四路加法的结果互不干扰硬件可以把它们排进不同的执行单元并行跑。关键在于两段代码在源码层面都没有任何并行的字眼没有线程、没有 SIMD 内建函数但版本 B 天然携带了四倍的可并行空间。这就是 ILP 的含义——它不是程序员显式写出来的并行而是指令之间的依赖关系决定的一种自由度。1.2 三种依赖决定了能并行到什么程度想判断一段代码有多少 ILP本质上就是回答一个问题这些指令之间的依赖关系是什么类型的第一类叫数据依赖也叫真依赖、RAWRead After Write。指令 j 要用指令 i 算出来的结果那 j 必须等 i 写完。这个依赖是程序语义决定的删不掉只能靠用别的方式算出同样的值来绕开——就像上面版本 B 里换成四个独立累加器。第二类叫名字依赖分两种反依赖 WARWrite After Read和输出依赖 WAWWrite After Write。它们的共同点是——两条指令之间其实没有数据流动只是恰好用了同一个寄存器名。比如add r1, r2之后接mov r1, r3这两条根本没有数据往来之所以看起来有先后纯粹是因为都要写r1。这类依赖是假的硬件可以用重命名把它消掉后面第 3 节会细讲。第三类叫控制依赖指的是一条指令是否执行取决于前面某条分支的判断结果。if (x 0) y 1;里的赋值就控制依赖于那个比较。这类依赖的处理方式跟前面两类不一样不能靠重命名只能靠预测或者延迟决策。我自己的经验是拿到一段跑得慢的循环先别急着换算法先把循环体里的依赖画一遍。绝大多数时候你会发现瓶颈是少数几条首尾相接的真依赖而不是指令总数太多。指令数量影响的是吞吐依赖链长度影响的是延迟两者要分开算。1.3 硬件的真实能力延迟和吞吐是两码事这里得引入一对常常被混淆的概念执行延迟和执行吞吐。以常见的浮点加法单元为例从两个操作数就绪到结果可用可能有 4 个时钟周期这叫延迟但如果流水线做得好每个周期都可以塞进去一条新的加法指令每周期 0.5 条甚至更多的吞吐这叫吞吐。这个区别是理解 ILP 的钥匙。如果一条依赖链上的操作延迟是 4 周期那么无论硬件能并行多少条指令这条链本身就只能每 4 周期推进一次。而一条链只占用了一个执行单元的 1/4 时间。要填满这个单元你需要至少 4 条独立的链。这就是为什么真正的高性能代码往往看起来写了四份一样的东西——那不是啰嗦是在给硬件喂独立的工作。我做过测算在一条延迟 4 周期、吞吐每周期 2 条的加法流水线上单链的利用率只有 12.5%四链能到 50%八链接近饱和。当然前提是你能找到这么多独立的工作而且内存带宽跟得上。很多时候硬件不是没能力并行是程序没给够活干。2. 流水线的天花板为什么 CPI 压不到教科书里的 12.1 经典五级流水与三种冒险教科书里那个取指、译码、执行、访存、写回的五级流水线是理解 ILP 的起点。理想情况下每个时钟周期有一条指令完成CPI 等于 1IPC 也等于 1。现实中这个数字很难拿到因为流水线会被三种东西打断。结构冒险指的是两条指令抢同一个硬件资源。比如同一条指令里既要访存又要写回而端口只有一个。老式单端口存储器上取指和取数据会冲突所以需要分开的指令 Cache 和数据 Cache或者插入气泡。数据冒险就是我们前面讲的数据依赖。经典的五级流水用转发forwarding解决大部分相邻指令的依赖实在解决不了的插气泡。控制冒险来自分支。取指阶段不知道下一条指令在哪只能等分支算出来或者猜。猜错了整条流水线要清空。这三种冒险里数据冒险和控制冒险是 ILP 最主要的敌人。现代处理器花在动态调度、重命名、分支预测上的晶体管几乎全是在跟它们死磕。2.2 发射宽度不是唯一变量一个常见的误解是处理器越宽越好。发射宽度从 4 提到 6理论峰值 IPC 从 4 提到 6但如果程序本身的 ILP 只有 2.5多出来的宽度就是白白闲置。更要命的是宽度越大前端取指越费劲。以我自己比较熟悉的几个实现为参考早年的 Core 架构是 4 宽解码后来的 Golden Cove 提到 6 宽解码但真正决定指令能不能持续流入后端的是微操作缓存有些文档里叫 DSB。微操作缓存命中时不需要重新译码可以按更高带宽往外吐微操作一旦缓存没命中退回传统取指译码路径带宽就会掉下来。这就是为什么循环体小到能塞进微操作缓存这件事本身就能带来明显收益——很多编译器的循环展开策略失败就是展开之后代码块太大把缓存撑爆了。后端也一样。发射宽度上去了重排序缓冲区ROB和物理寄存器堆也得跟着涨否则前端喂进来的指令只能在队列里排队。我见过不少程序在 ROB 满的时候停住IPC 掉到 1 以下明明执行单元还很空。这种时候性能计数器会告诉你后端绑定但具体是执行端口饱和还是 ROB 满得看细分事件。2.3 峰值 IPC 与实测 IPC 的落差一个通用的参考现代高性能处理器的峰值 IPC 在 4 到 8 之间但绝大多数通用工作负载的实测 IPC 在 0.8 到 2.5 之间。这个差距不是浪费而是 ILP 的物理上限决定的。提示评估一段代码时先把实测 IPC 算出来再看它的理论上限大概在哪。如果实测 IPC 已经接近 2.5说明你多半受限于依赖链或内存而不是指令太多如果实测 IPC 只有 0.6那多半是分支预测失败或者 Cache 抖动在拖后腿优化方向完全不同。我自己的做法是拿到一段代码先跑perf stat看 IPC 和分支失败率这两个数。这两个指标几乎能覆盖 80% 的方向判断IPC 低 分支失败率高去查分支IPC 低 分支失败率正常去查依赖链和内存访问模式。3. 真假依赖的分野寄存器重命名怎么把假串行变成真并行3.1 WAR 和 WAW 为什么是假的假设有两条指令一条读r5一条写r5。在顺序执行的语义下读必须在写之前完成否则读到的是新值。但在这里两条指令之间并没有真正的数据传递——写的那条并不需要读的那条算出来的结果。它之所以必须排在后面只是因为代码里复用了同一个寄存器名字。这就是 WAR先读后写依赖的本质它是名字冲突不是数据流动。同理WAW 是两个都写r5的指令谁后写谁的值为准也是名字冲突。既然问题是名字重复解决办法就很直接——给每次写分配一个新的物理位置。这就是寄存器重命名。硬件维护一张映射表把程序里看到的架构寄存器x86 里的rax、rbx或者 ARM 里的x0、x1映射到数量多得多的物理寄存器上。每次指令写某个架构寄存器就分配一个新的物理寄存器给它后续读这个架构寄存器的指令通过映射表找到最新的那个物理寄存器。重命名之后WAR 和 WAW 自动消失因为不同的写指向了不同的物理位置物理上就没冲突了。剩下只有真依赖 RAW那是消不掉的。3.2 物理寄存器堆、映射表与精确异常这套机制落到具体实现上涉及几个关键结构。寄存器别名表RAT保存当前的架构寄存器到物理寄存器的映射。指令经过重命名阶段时源寄存器查表得到物理寄存器编号目的寄存器分配一个新的物理寄存器并更新表项。物理寄存器堆的数量决定了能在飞的指令有多少。这个数字比架构寄存器多得多比如架构上只有 16 个通用寄存器物理上可能有一百多个甚至几百个。数量不够的时候重命名阶段就得停下来等这叫物理寄存器耗尽是后端绑定的一种。重排序缓冲区ROB负责维持顺序语义。指令乱序执行但必须按原程序顺序提交retire。ROB 里的每一条目记录指令是否完成、结果写到了哪个物理寄存器。当最老的那条指令完成时它就可以提交更新架构状态释放它的物理寄存器。这样如果中途发生异常所有没提交的指令都还没改架构状态可以直接丢弃程序恢复到异常点之前——这就是精确异常。注意ROB 的深度是一个很容易被忽略的瓶颈。一条访存指令如果 Cache 没命中它要在 ROB 里待几十甚至几百个周期。如果 ROB 不够深后面的指令全被堵住哪怕它们完全无关。这也是为什么长延迟访存密集的代码特别喜欢宽 ROB——它提供的不是并行度而是缓冲。3.3 一个具体的重命名过程拿一段短代码走一遍会更清楚add r1, r2, r3 # r1 r2 r3 sub r4, r1, r5 # r4 r1 - r5 add r1, r4, r6 # r1 r4 r6架构上r1被写了两次。第二条读的是第一条的结果真依赖必须保留第三条写r1与第一条写r1构成 WAW假依赖可以消除。重命名后可能变成这样P 表示物理寄存器add P10, P2, P3 # P10 P2 P3 映射表: r1 - P10 sub P11, P10, P5 # P11 P10 - P5 映射表: r4 - P11 add P12, P11, P6 # P12 P11 P6 映射表: r1 - P12三条指令写成三个不同的物理寄存器WAW 没了。第一条和第二条之间通过P10保留真依赖第二条和第三条之间通过P11保留真依赖这两条链都是程序语义要求的动不了。但第一条和第三条之间现在没有任何关系如果第二条不在中间挡着它们完全可以并行。举一反三看到一段代码里反复复用一个变量做中转你就该想到重命名能帮忙但重命名的能力有上限。如果你在一段循环里让几十条指令都写同一个变量物理寄存器堆可能会被撑满。这时候手动拆成几个变量让编译器有机会分配不同的寄存器收益可能很直接。4. 动态调度两种流派记分牌与 Tomasulo 的取舍逻辑4.1 记分牌顺序发射、乱序执行的第一次尝试记分牌是 CDC 6600 上用的动态调度方案也是最早把乱序执行落在硬件上的实现之一。它的核心是一张记分表记录每条指令的状态、功能单元的占用情况、以及哪些寄存器正在被哪条指令读写。记分牌的工作方式大概是指令按程序顺序发射但发射之后不等待操作数就绪而是直接进入功能单元等。功能单元在操作数齐了之后开始执行。执行完成后写回写回之前要检查 WAR 冲突——如果有更早的指令还没读这个寄存器就等一下。它有三个明显的局限。第一没有重命名所以 WAR 和 WAW 都得靠停顿解决。第二只有一个中央记分牌指令在同一个阶段里顺序处理发射速率受限。第三结构和状态是集中式的扩展性差。但它确立了一个重要观念顺序语义和乱序执行可以并存只要把结果生效这个动作局限在提交阶段。这个观念一直被沿用到现在。4.2 Tomasulo保留站加公共数据总线Tomasulo 算法在记分牌上做了两个关键改进直接奠定了后面几十年乱序执行的基本形态。第一个改进是保留站。每条指令发射后进入对应功能单元的保留站而不是直接进功能单元。保留站里的每条指令记录自己的操作数是否就绪以及如果没就绪应该从哪个功能单元的结果那里取。这就相当于把操作数等待这个动作从集中管理变成了分布式管理。第二个改进是通过重命名隐式消除 WAR 和 WAW。Tomasulo 不用架构寄存器名字来传递结果而是用保留站编号。写结果的时候把结果和保留站编号一起放到公共数据总线CDB上广播所有等着这个结果的保留站都在总线上监听匹配到自己的编号就取走。因为传递的是保留站编号而不是寄存器名WAR 和 WAW 自然就不存在了。CDB 是整个设计的核心枢纽也是瓶颈所在。一个周期里只能有一个功能单元广播结果如果有多个单元同时完成就得仲裁输的那个下周期再广播。所以后来的实现里 CDB 通常有多条或者改成结果直接写物理寄存器堆加唤醒网络的形式。这套机制带来的实际效果是乱序发射、乱序执行、乱序完成。指令只要能拿到操作数就可以走不用管前面还有多少没完成的。4.3 现代实现两种思路的融合现代的高性能核心基本是 Tomasulo 思想和 ROB 的结合体重命名阶段分配物理寄存器比 Tomasulo 的保留站编号更容易管理发射队列做唤醒和选择执行单元乱序执行ROB 负责顺序提交和精确异常。几个实现上的细节值得留意因为它们直接影响优化手段的有效性。发射队列的组织方式在变。早期是集中式的大队列现在很多实现用分布式的小队列按执行端口分组。好处是唤醒逻辑的复杂度降下来了坏处是负载不均衡的时候会有队列空转。这个细节对程序员来说不容易直接控制但能解释为什么某些指令组合的性能表现和理论吞吐不符。存储指令单独处理。访存指令有地址计算和数据两个部分地址随时可算数据得等。加载指令还需要和前面的存储指令做消歧判断有没有地址重叠。现代核心普遍支持推测加载在还没确认前面某条存储指令的地址时先假设不冲突把加载发出去等存储的地址算出来再验证错了就回滚重放。这套机制能大幅提升内存级并行但也容易在指针密集、别名关系复杂的代码上翻车。维度记分牌Tomasulo现代乱序核心发射顺序顺序乱序乱序完成顺序乱序乱序乱序WAR/WAW 处理停顿等待重命名消除物理寄存器重命名结果传递写寄存器堆CDB 广播唤醒网络加寄存器堆精确异常不支持部分支持ROB 保证主要瓶颈集中状态表单条 CDB发射队列与 ROB 深度我个人的体会是理解这两套机制的价值不在于记住细节而在于知道硬件为了解决依赖已经做了多少事。很多时候你手动优化半天没效果不是方法错了而是硬件早就自动处理掉了比如简单的 WAR 依赖硬件一眼就重命名了你手动拆变量反而是做无用功。真正值得动手的是硬件解决不了的那部分——真依赖链、分支、内存别名。5. 推测执行与分支预测ILP 挖得越深赌注押得越大5.1 分支预测的几代方案分支预测解决的是控制冒险。每遇到一个条件分支取指阶段就得选一条路走。等分支条件算出来再决策流水线要空好几拍提前猜猜对了一路畅通猜错了整条流水线清空重来。预测器的发展是一条从简单到疯狂的曲线。最早是静态预测比如向后跳转预测为跳、向前跳转预测为不跳利用的是循环倾向于继续、错误处理倾向于跳过的经验规律。然后是两位饱和计数器每个分支对应一个两位状态机连续两次猜错才翻转预测方向。这个设计的妙处在于它天然带有迟滞特性能抗住偶尔的例外情况——循环最后一次退出时猜错不会污染对这个分支的整体判断。再往后是两级自适应预测器用分支历史作为索引去查一张模式表。它能捕捉到形如每隔三次跳一次这种有规律但非单调的模式。Alpha 21264 上的锦标赛预测器还做了进一步工作同时跑全局历史和局部历史两个预测器用一个选择器决定信谁。当代主流是TAGE 类预测器用多组不同长度的历史做索引靠一个几何级数增长的基预测器加若干带标签的延伸预测器来覆盖。核心思路是历史越长能捕捉的规律越复杂但需要的表项越多用多个不同历史长度的表配合能在有限的存储里兼顾长短两种模式。现代的通用工作负载分支预测准确率普遍在 99% 以上这是乱序执行能深挖 ILP 的前提。5.2 推测执行的状态痕迹与屏障分支预测给出方向之后处理器就顺着预测的路径取指、译码、执行这一整套叫推测执行。推测执行的指令会在 ROB 里留下结果但不能改架构状态直到确认预测正确。一旦预测错误所有推测路径上的指令被丢弃物理寄存器回收取指重定向到正确的目标重新开始。这套回滚机制的正确性由 ROB 的顺序提交保证。有个容易被忽视的细节推测执行虽然不改变架构状态但会留下微架构层面的痕迹比如 Cache 行的填充状态、分支预测器的历史位。这些痕迹在高安全要求的场景里是需要考虑的因素工程上的常见做法是在关键边界处插入屏障指令切断推测让后续操作必须在前面全部退休之后才开始。代价是屏障会限制 ILP——两侧的指令不能重叠了。我在做敏感路径的代码时踩过这个坑加一道屏障性能掉了三成不加又过不了安全审查最后是把屏障范围缩到最小才平衡下来。5.3 预测失败的代价怎么算预测失败的代价可以粗算。流水线深度是 P从取指到分支条件算出需要 S 级那么一次失败大约损失 S 个周期后面重新填满流水线的时间。假设分支失败率是 f每 K 条指令一个分支那么每次分支的平均代价是 f × S 个周期。代入一组常见的数S 取 15 到 20f 取 1%每 6 条指令一个分支那么平均每 6 条指令浪费 0.15 到 0.2 个周期。看起来很小但如果某个特定分支的失败率达到 30%代价就上去了——每 6 条指令浪费 4.5 到 6 个周期IPC 直接被打到地板上。提示优化分支之前先测。perf stat -e branches,branch-misses两行命令就能给出全局失败率再用perf record -e branch-misses -g可以定位到具体哪个分支。我见过太多人凭直觉去优化循环结果失败的分支在完全不相干的地方。降低分支失败代价的常见手段有把大概率走的那条路放在顺序位置减少需要预测的跳转方向数量、把分支数量本身减少循环展开、合并条件、在数据层面上消除分支用条件传送指令替代短小的 if-else。这里有个反直觉的结论数据无分支化不一定更快。条件传送要两个分支都算一遍如果两侧的计算量都不小无分支反而更慢。只有在两侧计算都很轻比如就是取两个值之一的时候无分支才稳赢。这个判断没有通用公式得看具体工作负载。6. 编译器能替硬件做多少循环展开、软件流水与 VLIW 的路线分歧6.1 循环展开收益是有限的代价是明确的循环展开是编译器最常用的提 ILP 手段也是被误解最多的一个。基础的展开就是把循环体复制几份减少迭代次数从而减少分支和循环开销。真正的价值不在减少分支而在给调度器腾出空间。原来的循环体里不同迭代之间的指令被循环回边隔开编译器不敢随便重排。展开之后多个迭代的指令摆在同一个基本块里编译器就有机会把独立的指令重新排列让依赖链错开。但展开的代价也很明确。第一是代码膨胀展开 4 倍意味着四倍的代码体积容易把微操作缓存打爆。第二是寄存器压力每一份展开后的循环体如果用了独立的寄存器物理寄存器就不够分了如果复用又会引入名字依赖。第三是尾部处理n 不是展开因子的整数倍时需要额外代码。我自己的经验是展开因子不是越大越好2 到 4 是常见的甜点区。编译器一般不会主动帮你展开得太激进因为收益高度依赖目标微架构的缓存大小和发射宽度。用-funroll-loops让编译器自己判断是个合理起点但真正在意性能的时候我倾向于手动展开因为只有我知道数据布局和实际调用模式。6.2 软件流水让循环的各个阶段重叠起来软件流水也叫模块调度解决的是另一个问题循环体内部的依赖链比循环迭代还长的时候怎么让不同迭代重叠执行。思路是把循环体按阶段切开第 i 次迭代的第一阶段和第 i-1 次迭代的第二阶段同时跑。听起来跟流水线生产一样实际上就是同一个道理。它特别适合那种每一步都依赖上一步结果但步骤内部可以拆的循环比如数论算法、信号处理里的滤波器。实现上最麻烦的是开始和收尾。流水线要填满需要若干迭代的序曲清空需要若干迭代的尾声所以软件流水在小循环上不划算。编译器判断一个循环值不值得做软件流水通常会看迭代次数是不是足够多、循环体是不是足够规整。迭代次数不确定或者很少的循环编译器往往直接放弃。这也是个实用提醒如果你写了一个循环循环次数在运行期才知道别指望编译器做太激进的变换。把常用的几个尺寸做成固定长度的小循环或者加上#pragma提示能实测出差别。6.3 VLIW把调度权完全交给编译器的路线前面讲的都是硬件动态调度、编译器辅助的路线。还有一条完全相反的路VLIW超长指令字把调度权整个交给编译器。VLIW 的思路是一条超长指令里打包好几个可以并行执行的操作槽编译器负责在每个槽里填指令硬件只负责按包发射不做任何乱序调度和依赖检查。硬件因此可以做到非常简单功耗也低执行单元可以堆很多。代价同样清晰。第一编译器必须能准确判断依赖遇到指针别名、间接跳转就只能保守处理填不满的槽位只能填空操作浪费大量指令带宽。第二二进制兼容性差换一代硬件、槽位数变了代码就要重新编译。第三代码体积膨胀对指令 Cache 的压力很大而 Cache 没命中的代价在长流水线上会被放大。这条路线在通用计算上没占到便宜但在规整的、可预测的、对功耗敏感的场景里活得很好比如数字信号处理和图形处理器的着色器核心。GPU 上那种一大群线程同时执行同一条指令的模式本质上也是拿显式并行换硬件简单只不过换了种粒度。6.4 别名分析编译器不敢重排的真实原因最后说一个特别容易被忽略的问题。编译器想重排两条访存指令必须先确认它们访问的地址不重叠。但很多时候它无法确认。void scale_add(float *dst, const float *src, const float *k, int n) { for (int i 0; i n; i) { dst[i] dst[i] src[i] * k[i]; } }这段代码里如果dst和k指向同一块内存循环内部就出现了跨迭代的依赖编译器就不能把多次迭代的加法重排并行。编译器默认假设可能有别名所以只能保守生成代码。解决办法是告诉它。C99 起有restrict关键字可以声明这个指针是访问那块内存的唯一途径void scale_add(float * restrict dst, const float * restrict src, const float * restrict k, int n) { ... }加上之后编译器就能放心并行化迭代。实测下来在访存密集的循环里这一个关键字的收益经常在 20% 到 50% 之间属于性价比最高的一类改动。类似的手段还有对齐提示alignas、__builtin_assume_aligned能让编译器用上对齐访存指令减少指令条数。7. 落到我的机器上用性能计数器把 ILP 瓶颈量出来7.1 四个基础指标和它们能回答的问题性能计数器是把 ILP 分析落到实处的工具。Linux 上最常用的是perf起步只需要四个事件。perf stat -e cycles,instructions,branches,branch-misses ./a.out得到的结果能回答大部分方向性的问题。instructions / cycles就是 IPCbranch-misses / branches是分支失败率cycles / instructions是 CPI。进一步的加上 Cache 相关的事件perf stat -e cycles,instructions,\ L1-dcache-loads,L1-dcache-load-misses,\ LLC-loads,LLC-load-misses ./a.outL1-dcache-load-misses / L1-dcache-loads一级数据缓存未命中率超过 5% 基本就能确定是内存访问模式的问题了。我自己排查问题的顺序通常是这样的。先看 IPC低了再拆。如果 IPC 高比如 2 以上但程序还是慢那问题多半在指令总数太多方向是算法或向量化。如果 IPC 低且分支失败率高查分支。如果 IPC 低且 Cache 未命中率高查数据布局和访问步长。如果 IPC 低、分支正常、Cache 正常那基本就是真依赖链在卡。7.2 用 Top-down 方法把后端绑定再拆细perf stat给出的信息是粗粒度的。当它告诉你后端绑定的时候还有一层细分要做。Intel 的 Top-down 分析把每个周期的槽位分成四个桶分类含义常见原因典型对策Retiring槽位给了真正退休的指令正常无Bad Speculation猜错分支或回滚丢弃分支预测差减少分支、改数据布局Front-End Bound取指和译码跟不上指令缓存未命中、微操作缓存未命中、分支密集缩小循环体、代码对齐Back-End Bound执行端口满或数据没到端口饱和、依赖链长、Cache 未命中平衡端口、拆依赖链、预取后端绑定还要再分两半Core Bound是执行单元本身忙不过来或者依赖链太长Memory Bound是数据没到或者存储缓冲满。这两者的对策完全不同前者要平衡执行端口和拆依赖后者要改数据布局。在能用的机器上perf stat -M或者toplev这类工具可以直接给出这套分解。没有这些工具的话靠手工组合事件也能凑出个大概。7.3 一次完整的排查记录拿一段实际的代码走一遍。任务是把两万个浮点数做加权求和原始实现就是一个朴素循环。第一轮测出来 IPC 0.75分支失败率 0.3%L1 未命中率 2%怎么看都不像有明确瓶颈但 IPC 就是上不去。于是加测执行端口相关的事件发现端口利用率很不均衡加法单元接近饱和其他单元很闲。这就说明瓶颈是依赖链——所有加法都走同一条链链的延迟决定了吞吐。改成四路部分和之后IPC 涨到 1.9端口利用率也均匀了。再往上推把部分和加到八路IPC 反而掉回 1.5一扫原因是加载端口的压力上来了每周期只能发两条加载八路展开导致加载指令的排布变得不均衡。最后定在四路配合编译器自动向量化整体加速比在 3.2 倍左右。这个过程中的每一步都是被计数器指路的没有一步是凭感觉。我强烈建议所有做性能优化的人都养成先测再改的习惯凭直觉优化的命中率大概只有三成而每次测量只要几十秒。8. 代码层面的榨取手法哪些改动真的有用8.1 拆依赖链把循环里的串行累加改成分组归约拆依赖链是最直接也最稳定的 ILP 优化。前面已经给过浮点求和的例子这里补充几个实操细节。浮点加法不满足结合律分组归约会改变结果。对精度敏感的场景这个改动不能随便做或者需要保留原本的顺序。整数加法、按位运算没有这个问题可以放心拆。链数选多少取决于目标硬件的执行延迟和吞吐比。延迟 4 周期、吞吐每周期 2 条需要的链数是 8 才能打满吞吐。但内存带宽往往先成为瓶颈实际测下来 4 条链通常是最佳平衡点。我一般从 4 开始用计数器试着往上加看到 IPC 不再涨或者开始掉就退回来。还有一个容易忽略的地方归约的最后一步。所有部分和最后要合并成一个值这段合并本身也是一条依赖链。如果部分和的数量多收尾的树形归约要注意别写成线性的。8.2 数据布局内存级并行MLP也算一种并行数组结构体AoS和结构体数组SoA的选择经常被当成缓存优化的问题其实它对 ILP 也有直接影响。用 AoS 布局时访问p[i].x要跨过p[i].y、p[i].z这些没用的字段浪费带宽还占 Cache 行。用 SoA 布局所有x连在一起一个 Cache 行能装下更多有用数据预取器也更容易预测。更重要的是SoA 让多个独立的加载可以同时发出。现代核心能同时维持多个未完成的 Cache 未命中这个能力叫内存级并行。AoS 布局下跨越无用字段的加载会占用额外的未完成槽位SoA 布局下每一个未完成加载都带来有用数据。实测下来把一个粒子系统从 AoS 改成 SoA只改数据结构不动算法性能提升能到 40% 以上。指针追逐是另一类典型问题比如链表遍历。每一次node node-next都要等上一次加载完成才能算出地址延迟完全暴露。这类代码几乎没有任何 ILP 可言可行的手段只有把链表改成数组、或者在遍历时分段预取。前者改变数据结构后者增加指令开销都得权衡。提示判断是不是延迟暴露型问题看一个指标就够了——Cache 未命中数乘以访存延迟如果这个乘积接近总周期数那你的程序基本就是在干等内存。这种情况下任何指令层面的优化都不会有效果。8.3 无分支化什么时候该做什么时候别碰无分支化就是把短小的 if-else 改成条件传送。现代编译器对三元表达式和简单 if-else 通常会自动生成条件传送不需要手动干预。需要手动的时候一般是这样几种情况分支两侧的计算都很廉价、分支模式不可预测、分支体足够小。反过来不该做的情况也很明确两侧计算量都大、分支本身高度可预测、或者代码里有副作用比如函数调用、内存写入。最常见的一种错误是拿无分支化改造一个 99% 情况下都走同一侧的分支结果把罕见路径的代价平摊到所有情况上反而变慢。判断依据就是那个分支的失败率。失败率低于 1% 的分支动它基本是负收益。失败率高到 20% 以上、而且两侧计算都很轻才值得考虑。8.4 编译选项几个真正有效的组合编译选项里真正影响 ILP 的就是那么几个用不着面面俱到。-O2是通用起点-O3会多做向量化和循环变换但代码膨胀也更明显某些情况下反而不如-O2。我的一般做法是两个都测选实测更快的那个而不是默认用-O3。-marchnative让编译器针对当前机器的指令集生成代码能启用更宽的向量指令和新的位操作指令。代价是二进制不能跨机器用。-funroll-loops让编译器自主决定展开配合-fprofile-use使用效果更好——有了实际运行的频率数据编译器能判断哪些循环值得展开、展开多少。-flto做链接期优化能跨编译单元做内联和重排对那种函数被拆得很散的代码提升明显。缺点是编译时间变长构建流程要调整。我自己的常用组合是-O3 -marchnative -flto -fno-math-errno配合一组代表性输入跑 profile。这套组合在数值计算类代码上的提升通常有 10% 到 30%剩下的就得靠手工改了。9. 一些容易被忽略的边界情况分享几个我自己踩过的、文档里基本不会写的坑。第一别在热点循环里做函数调用。现代编译器有内联能力但跨编译单元内联需要 LTO函数指针调用基本内联不了。一旦循环体里出现函数指针整个循环的 ILP 就被切碎了。如果确实需要多态把分支提到循环外面或者手写几个特化版本。第二注意整数除法和取模。这两个操作在很多实现上延迟高、吞吐低而且不能流水化得好。循环里带i % 3这种如果除数在编译期已知编译器会转成乘法和移位如果除数是变量每次迭代都得出结果才能继续ILP 直接归零。能换算成减法或者用位运算的尽量换。第三小心伪共享。这条主要针对多线程代码但它是 ILP 分析的一个延伸边界。两个线程写同一 Cache 行里的不同变量会导致 Cache 行在两个核之间反复弹跳。解决办法是做缓存行填充把每线程的状态按 64 字节对齐排开。这个改动听起来很土但在计数器确认为 Cache 抖动的时候效果立竿见影。第四微操作缓存是隐形的约束。有时候一个函数只改了几行性能就掉了一大截原因可能是循环体越过了某个大小阈值从微操作缓存里掉了出去。这种时候用perf stat看前端绑定比例会很明显。对策是精简循环体把不常走的分支挪出去。第五性能计数器自己也会骗人。多路复用的事件集合、抽样带来的统计误差、虚拟化环境的干扰都会让数字不准。我的习惯是同一个测量至少跑三遍取最小值而且不要用那种跨越整个进程生命周期的平均值去判断某个循环——用perf record定位到具体代码行再看局部数据。最后说个我自己的习惯。每次做完一轮优化我都会把改前的版本和改后的版本放在同一台机器上用同一组输入再测一遍包括冷启动和热启动两种情况。冷启动能暴露取指和 Cache 的问题热启动能暴露依赖和端口的问题。这两种场景下最优的写法有时候真不一样比如冷启动时小循环体更好热启动时展开更多反而有优势。分清自己面对的是哪种场景比记住任何一条具体优化技巧都重要。
返回列表