十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

数据依赖与控制依赖:决定程序性能上限的隐形骨架

数据依赖与控制依赖:决定程序性能上限的隐形骨架 依赖关系这种东西平时写代码的时候看不见摸不着但一旦你开始做性能优化、搞并行计算、查一个“莫名其妙慢一倍”的诡异问题时它就会从各个角落冒出来。我最早真正被数据依赖“收拾”是在一次把单线程图像处理改成多线程的时候每个像素明明独立处理结果一开多线程反而更慢排查到最后才发现是共享缓存数组上的伪共享加循环携带依赖在“互相喂数据”。从那以后我看循环代码第一件事就不再是数括号而是先画依赖。这篇文章主要聊清楚两件事数据依赖和控制依赖到底是什么以及它们如何在编译器优化、CPU乱序执行和手动并行化过程中决定性能上限。同时会结合最近大家在讨论的前缀和技巧拆解一个非常经典的“依赖链换并行度”的解法。适合正在学编译原理、做高性能计算或者被各种诡异的性能问题折磨过的开发者。1. 从一条指令到整段程序依赖关系是性能关键的隐形骨架1.1 一段 C 代码里的隐形锁链先看一段非常简单的代码int compute(int n, int *a) { for (int i 1; i n; i) { a[i] a[i - 1] * 2 1; } return a[n - 1]; }这段代码里藏着一个极其典型的循环携带数据依赖第i次迭代读取a[i - 1]而这正是第i - 1次迭代写入的结果。也就是说第 2 次迭代必须等第 1 次迭代算完才有数据可用第 3 次必须等第 2 次依此类推。这个依赖把整个循环串成了一条“单行道”CPU 的多个执行单元帮不上忙编译器的自动向量化也只能干瞪眼。很多人第一次听到“数据依赖”这个词会觉得这是编译器内部才需要考虑的概念。但实际上现代 CPU 在执行指令时有一个叫做乱序执行Out-of-Order Execution的机制。它会在硬件层面动态分析指令之间的依赖关系让没有依赖的指令提前执行有依赖的指令只能等待。如果一段代码里依赖链过长即使 CPU 的流水线再宽、执行单元再多也只能在这条链上“一步一停”地往前走。这就是为什么我们说依赖关系是程序性能的隐形骨架。它不像算法复杂度那样写在教科书里也不像内存带宽那样可以靠工具直接测出来但它实实在在地决定了你的代码能够利用多少底层硬件能力。1.2 编译器、CPU 和并行化都在看同一张图编译器在做优化和 CPU 在执行指令时本质上都在做同一件事分析数据是怎么流动的然后在不改变结果的前提下尽可能多地打乱原有的执行顺序。编译器手里拿着的是依赖图Dependence Graph图的节点是单个操作或语句边代表依赖关系。只要两条指令之间没有依赖编译器就可以安全地重排、合并、并行化它们。指令调度Instruction Scheduling、循环展开Loop Unrolling、软件流水Software Pipelining这些优化全部建立在“识别依赖并避开它们”的基础上。CPU 手里拿着的则是一张几乎相同的图只不过把粒度缩小到了微操作级。Intel 和 AMD 的处理器内部有一套非常复杂的调度器它会为每条指令记录它依赖于哪些正在执行中的指令。只要有空闲的执行单元调度器就挑一条“所有依赖都已满足”的指令发射出去。这套机制让人叹为观止但前提是代码里真的有可挖掘的并行度。当你想把一段串行代码改成多线程或者用 SIMD 指令加速时更加绕不开这个问题。多线程是把循环的迭代分给多个核心去跑但如果迭代之间通过数据依赖相连你拆分的就是一条甩不掉的铁链子而不是一把筷子。向量化则是把多个迭代的相同操作打包成一条 SIMD 指令但如果第i个迭代需要第i - 1个迭代的结果那么新向量中每个元素的计算也依然被依赖关系锁死。理解依赖关系本质上就是理解所有并行化手段的边界。接下来就先把“数据依赖”这个最核心的概念彻底拆开。2. 数据依赖三类依赖和一个判断套路2.1 RAW、WAR、WAW先给读写冲突分个类数据依赖的定义很朴素如果两条指令访问同一个内存位置或寄存器并且其中至少有一条是写操作那么它们之间就存在数据依赖。按访问顺序细分可以分成三类名称全称访问模式通俗理解能否消除真依赖流依赖Read After Write先写后读后一条指令要读前一条指令刚算出的值不能只能通过改变计算顺序或近似算法绕开反依赖Write After Read先读后写后一条指令要覆盖前一条刚读取过的变量可以通过变量重命名消除输出依赖Write After Write先写后写两条指令写同一个位置最后的写入者决定最终值可以通过变量重命名消除用一个最朴素的生活场景类比真依赖像做菜时刚炒完必须立刻装盘锅和铲子此刻不能被别人占用反依赖像你在读一本参考书另一个人打算在你看完之后把书借走他只要等你看完就行换本书读更省事输出依赖像两个人先后往同一个储物柜里放东西只有最后放进去的会被看见换一个柜子存放就能并行。具体到代码里看// 真依赖RAW x a b; // 写 x y x * 2; // 读 x必须等上一步完成 // 反依赖WAR sum arr[i]; // 读 sum sum reset; // 写 sum理论上可以换个变量名 // 输出依赖WAW tmp a * b; // 写 tmp use(tmp); tmp c d; // 再次写 tmp最后一次写入赢判断依赖的核心意义在于真依赖直接决定了关键路径的长度你没法消除它只能尽量缩短它反依赖和输出依赖则是“纸面冲突”它们只是因为变量名或者物理寄存器不够用才产生的完全可以通过给变量重新起名字来消除。2.2 跨迭代依赖才是并行化的头号杀手循环携带依赖前面提到的那段a[i] a[i - 1] * 2 1是循环携带依赖的典型形态。在实际工程中这种依赖关系往往隐藏得很深不像单语句里那么直白。比如下面这个例子看着像是一个标准的求和规约for (int i 0; i n; i) { result arr[i] * k; }result在每次迭代中先被读取再被写入所以这里同时存在反依赖WAR和输出依赖WAW。严格来说每次迭代都依赖前一次迭代的result值但由于加法的交换律和结合律编译器会把它识别成规约Reduction模式用树形累加或 SIMD 指令重排计算顺序。这就是为什么-O3下这段代码可以自动向量化而前面a[i] a[i - 1] * 2 1不行。判断一个循环是否可以被并行化方式很简单把循环体想象成一台加工流水线问自己第i个零件加工需不需要第i - 1个零件加工到一半的产物如果不需要这个循环就可以任意切分给多个工人如果需要就得先看看那条依赖链能不能被拆短甚至拆没。循环携带依赖还有一个容易被忽略的变体通过数组下标错位形成的依赖。比如for (int i 2; i n; i) { a[i] b[i] a[i - 2]; }这条依赖的距离是 2意味着第 0, 2, 4, … 个迭代之间互相依赖奇数迭代之间也互相依赖但奇偶两组之间是独立的。这种“步长依赖”给优化留了口子编译器可以把奇偶迭代分组处理但前提是开销小于收益。2.3 判断依赖的实用方法从代码到依赖图在日常工作中我没有每次都去做严格的数据流分析但练出了一套快速判断依赖的方法这里分享出来第一找“跨语句的变量”。看一个变量是否在函数内多个地方被读写尤其是循环体内。如果它在一个循环里同时被读和写重点检查这次写入是否会在下一次迭代中被读到。第二找“重复写同一位置的数组”。数组下标表达式固定不变比如a[index]在循环里被反复赋值那就是 WAW一个地方读、另一个地方写那就可能是 WAR 或者 RAW。第三画“谁先谁后”的箭头。不用画正式依赖图纸上画几行代码把写操作到后续读操作之间画上箭头箭头穿过的迭代越多依赖链越长并行化的阻力越大。如果手头有工具我会在关键代码上用 LLVM 的opt跑一遍 dot 格式的依赖图或者直接用-Rpass-analysisloop-vectorize让编译器把“为什么不能向量化”的原因打出来。后面第四部分会展开讲怎么用工具辅助分析。3. 控制依赖分支背后那只看不见的手3.1 支配关系谁决定了你一定能走到这里数据依赖说的是数据流动的方向而控制依赖说的则是语句执行与否对另一条语句的制约。先讲一个概念支配关系Dominance。在程序的控制流图CFG中如果从入口到某个节点的所有路径都必经节点 B我们就说 B 支配这个节点。同理如果一个分支的“二选一”结果决定了另一条语句要不要执行那么这个语句就控制依赖于这个分支。举一个非常典型的例子if (x 0) { y a / x; } z y 1;这里y a / x这个操作是否执行完全由x 0的真假决定所以操作“控制依赖”于那个条件分支。而最后的z y 1不管分支怎么走都会执行因此它不控制依赖于这个分支。这种依赖关系在编译优化里极其重要因为它决定了编译器能不能“大胆地移动代码”。上面这段代码如果编译器想把y a / x提前到条件判断之前执行就要非常小心万一x 0直接提前执行就会产生除零异常即便实际上根本不会进入 if 分支。所以编译器宁可创建一条“慢速路径”和一条“快速路径”也不轻易把控制依赖里的语句提到外面。3.2 控制依赖与代码移动的安全底线控制依赖是编译器做代码提升Hoisting和代码下沉Sinking时必须遵守的底线规则。如果一条语句被从控制依赖的分支里提升到了分支之前它就会在本来不该执行的时候也执行万一这条语句本身有副作用除零、空指针解引用、修改全局状态程序行为就会发生变化。为了在不改变行为的前提下让代码更快编译器通常采用猜测执行Speculative Execution的思路。比如if (cond) { x a[i]; // 假定 i 在合法范围内 }编译器可能会生成无分支的猜测加载指令同时再插入一个范围检查如果检查失败就丢弃猜测的结果。这个做法是安全的因为它不会真正暴露副作用但代价是即便分支不成立也占用了执行资源。CPU 硬件层面的分支预测也是同一套思路的“二弟”。CPU 会猜分支往哪个方向走提前执行那些控制依赖的指令如果猜错了就把已经执行完但没提交结果的指令全部作废然后跳转到正确方向。所以控制依赖在硬件层面带来的代价主要是预测失败时的流水线清空惩罚。这也是为什么很多性能优化技巧都强调“尽量让分支可预测”本质上就是在减小控制依赖对 CPU 流水线的影响。3.3 分支预测、调度与控制依赖的妥协如果控制依赖太多编译器会尝试用分支消除Branch Elimination或条件指令如 CMOV条件传送指令来摆脱分支。这里权衡的核心是分支指令本身开销很小但分支预测失败的代价很高可能达到几十个周期的停顿。如果分支的条件取决于一个在运行时才产生的随机数据那预测成功率可能就是 50%罚时摊到每次执行上相当可观。这时改用cmov这样的条件传送指令虽然预测失败与指令延迟的博弈仍然存在但可以让执行时间更稳定不受分支模式影响。一个经典例子是实现maxint max(int a, int b) { if (a b) return a; return b; }这条逻辑分支的代价高度依赖输入数据的分布。如果输入接近随机分支预测错误率会逼近 50%。改写成int max(int a, int b) { return a b ? a : b; }很多编译器会直接生成cmov指令性能曲线立刻变得平滑。当然cmov不是万能药它两个操作数都必须先算出来如果分支体里有“只有在分支成立时才允许计算的昂贵操作”比如可能抛出异常的函数调用就不能用条件传送替代。熟悉这些边界才算真正把控制依赖吃透了。4. 实操现场当依赖关系挡住性能时怎么办4.1 让工具开口说话用 LLVM 和编译器日志定位依赖问题在真实工程里我不建议仅靠肉眼人肉分析依赖链尤其当循环体有一两百行、循环层数又嵌套三层时。手算很容易漏而且效率极低。我的习惯是先让编译器和分析工具开口说话。最常用的是编译器的向量化诊断日志。Clang 和 GCC 都支持把为什么没做向量化的原因打印出来clang -O3 -Rpass-analysisloop-vectorize -Rpassloop-vectorize myloop.c如果某个循环被成功向量化会输出类似“vectorized loop (vectorization width: 4, interleaved count: 2)”的信息如果失败会输出类似“loop not vectorized: cannot prove it is safe to reorder instructions”的提示。这类提示往往直接点出了阻碍向量化的依赖省去大量人工推演。-Rpass-missedloop-vectorize会给出更详细的内容包括是否因为“possible dependence between ”某个数组访问而失败。这是一个被低估的调试手段尤其是当你面对别人写的复杂循环时它能帮你快速确认代码中某些不显眼的依赖。如果想要静态图分析可以用 LLVM 的opt工具导出控制流图或依赖图。下面是一个最简流程clang -O2 -S -emit-llvm -o example.ll example.c opt -passesdot-cfg example.ll # 会生成 .dot 文件然后用 Graphviz 打开关键不是看整张 CFG 的形状而是看循环体的基本块之间哪些边是回边以及哪些内存访问被标记上了依赖关系箭头。4.2 前缀和解决数据依赖把串行链换成并行金字塔最近“前缀和解决数据依赖”这个话题讨论度很高我们仔细聊一下因为它用到的思路极度优雅而且对“消除循环携带依赖”有直接的启发。先回顾经典串行前缀和问题// 计算 a[0] a[1] ... a[i]结果写入 prefix[i] prefix[0] a[0]; for (int i 1; i n; i) { prefix[i] prefix[i - 1] a[i]; }这个循环有极强的循环携带依赖。每个元素必须等前一个元素算完所以整个循环的关键路径长度是 n 次加法无论你用多少核都是这个时间。但是前缀和算法有一个非常漂亮的并行版本叫做 Hillis-Steele 扫描// 并行前缀和上扫阶段也就是 work-efficient 版本的第一步 offset 1; while (offset n) { for each i in [offset, n) in parallel: a[i] a[i - offset]; offset * 2; }这个算法依然写回了同一个数组但巧妙之处在于它把依赖的“步长”从 1 变成了 1、2、4、8…… 第i个元素在第k步只需要等第i - offset个元素的结果而i - offset在第 k 步时已经算完了。并行度大幅提升。但上面这段展示的循环其实还是写在同一个数组里本质上仍有跨迭代依赖只是步长变了。真正的并行实现通常用两个数组交替交换将每个迭代之间的依赖彻底切断。工程上如果需要在多个线程上计算前缀和标准做法是三步走把数组切成 p 块每个线程先块内算前缀和。单独串行或并行计算块与块之间的“块前缀和”。每个线程再把块前缀和加到自己的块内元素上。这个思路的核心是把“一条超长依赖链”拆成“多条短路依赖链 一条短链”用减少关键路径长度来换时间。对 CPU 并行、GPU 编程甚至分布式计算背后原理完全相同。前缀和能解决的数据依赖问题本质上是累积式依赖也就是每个输出都等于前一个输出加上一个新输入。如果你在代码里发现“每次迭代都要读取上一次的累积值”先不要急着放弃并行化试着问自己能否把累积过程改写成可分治、可合并的形式加法可以乘法也行但比加减复杂的运算比如某些随机数生成状态更新就不行了。这个判断条件非常实用。4.3 进阶优化链路变量重命名、循环展开与软件流水处理数据依赖除了前缀和这种“算法级解法”编译器也有自己的三板斧。第一部分是变量重命名用于消除反依赖和输出依赖。// 有 WAR 依赖的代码 t a[i]; sum t; t b[i]; product * t; // 重命名后两个 t 变成 t1 和 t2 t1 a[i]; sum t1; t2 b[i]; product * t2;看起来只是改了个名字但在指令调度时CPU 和编译器都不会再认为这两段运算之间有任何关系它们可以并行执行。第二部分是循环展开。循环展开的好处是把循环体里的多个迭代放在同一个基本块里让调度器有更多空间把无关指令穿插起来隐藏延迟。但展开多了指令缓存压力会变大所以编译器通常会在“展开因子”上做权衡。第三部分是软件流水这是编译器解决循环携带依赖的终极大招。它类似工业流水线把第 i 次迭代的一部分工作比如 load 数据提前到第 i-1 次迭代做再把第 i 次迭代产生的结果推迟到第 i1 次迭代使用从而把每次迭代的关键路径缩短到一段可以并行处理的范围。一个非常简单的软件流水例子// 原始循环每次迭代要先等 load 完成再算 for (i 0; i n; i) { y[i] x[i] * k; } // 软件流水后提前 load 下一步要用的数据 tmp x[0]; for (i 0; i n - 1; i) { tmp_next x[i 1]; y[i] tmp * k; tmp tmp_next; } y[n - 1] tmp * k;真实编译器的软件流水要复杂得多它需要处理循环边界、寄存器分配、异常安全等问题但原理就是上面这个味道——把依赖链上的等待时间用提前量填满。5. 真实项目中的依赖问题排查与避坑记录5.1 典型问题速查表排查依赖相关问题时有几个高频场景这里整理成速查表方便大家干活时对照症状可能的依赖原因快速验证方法循环无法自动向量化编译日志提到 dependence循环体内存在编译器无法判断是否重叠的指针访问加restrict关键字或用数组下标而非指针别名访问多线程版本比单线程还慢循环携带依赖或伪共享涉及同一缓存行先注释掉写入操作跑一遍再用perf stat看缓存未命中分支密集型函数性能抖动大控制依赖导致分支预测失败率高替换分支为算术运算或改写成cmov风格某段代码延时固定但吞吐上不去单条长依赖链限制了 ILP试试变量重命名和循环展开看关键路径是否缩短数据量翻倍后运行时间超线性增长深层循环带长距离依赖缓存命中率骤降对依赖距离为 d 的循环考虑分块Tiling5.2 容易踩的三个坑第一个坑盲目给指针加restrict。restrict的意思是告诉编译器“这个指针是访问这块内存的唯一方式”你确实能因此绕过编译器无法证明别名关系而放弃优化的问题但这必须保证代码真的满足这个语义。如果有两个指针在循环里被交错访问加了restrict就是未定义行为实测算错结果还是小事产生精灵般的崩溃就麻烦了。第二个坑忽视数组边界的依赖。有些依赖不是显式地写在同一个变量上而是通过数组下标重叠产生的。比如二维矩阵按行遍历时两个线程分别处理相邻行但某行的尾部写入和下一行的头部读可能落在同一个缓存行或同一内存区域就会产生微妙的竞争和性能下跌。排查时打开 ThreadSanitizer 或者仔细检查内存访问模式能省很多事。第三个坑一上来就手动改写。用前缀和解决累积依赖虽然很妙但不是所有场景都值得。转换后的代码可读性较差维护成本更高。如果数据规模只有几十个元素或者累积运算本身只有两三行简单的整数运算老老实实串行跑往往比任何并行优化都快。优化前请先量化先测基线再决定是否值得“动刀”。提示在动手优化依赖问题之前先想清楚“瓶颈到底在不在依赖链上”。很多循环看起来有依赖实际运行时间却是被内存带宽或随机访问延迟主导。先用perf stat看一两个关键指标再决定优化方向。5.3 关于依赖分析工具的选型心得工具这块除了前面提到的 Clang 向量化诊断我还会在特定场景下用 Intel VTune 的 Program Structure Analysis 来看热点循环的依赖图它对复杂分支和函数调用间的控制流展示很直观。开源项目里Valgrind 的 cachegrind 可以模拟 cache 行为间接帮你判断数据访问模式是否引发伪共享但它不能直接输出依赖图。真正的依赖图分析还是要靠 LLVM 的 pass虽然学习曲线略陡但一旦跑通收益很大。编译器文档永远是第一手参考。GCC 关于向量化的官方维基和 LLVM 的 Vectorization Notes 都写得非常细值得反复读。最后聊一点个人体会依赖分析能力本质上是一种“读代码的直觉”。工具可以帮助确认但真正的大头还是平时写代码时多留个心眼多问一句“这一轮的结果下一轮要用吗”。我在实际项目中把数据依赖和控制依赖放在一起分析之后很多之前看不懂的性能瓶颈都像拼图一样突然对上了。尤其是当你学会用“前缀和思路”去重新审视那些被长依赖链卡住的循环时那种豁然开朗的感觉特别值得体验。下次你碰到一个让人抓耳挠腮的串行循环先别急着硬刚拆开依赖看看再动手。
返回列表