首页 / 资讯中心 / 文章详情

C++编译器消除重复代码:核心技术与汇编验证方法

C++编译器消除重复代码:核心技术与汇编验证方法 ★ FEATURED ARTICLE
写C的老手多少都会好奇一件事明明我在好几处写了同一个表达式或者循环里算了几遍结果根本没变的值生成的机器码里到底有没有老老实实把这些重复计算全部执行一遍答案多半是没有。C编译器在生成目标文件之前会对中间表示做大量清理工作其中很重要的一类就是“消除重复代码”。这不仅是优化性能也是在帮你收拾源代码里那些无心留下的冗余。我自己最喜欢的验证方式就是编译后直接看汇编或者在优化报告里数一数某个乘法指令到底出现了一次还是三次。今天这篇文章就从一个一线开发者的视角把编译器究竟用什么手法干掉重复代码、你该怎么观察验证、以及哪些时候它确实无能为力一次讲清楚。1. 编译器眼中的“重复代码”到底是什么1.1 不只照顾显式重复还有隐藏的冗余很多人一听“消除重复代码”第一反应是哦编译器会把源码里完全相同的两行合到一起。其实没那么简单。源码层面的重复只是冰山一角“重复”在编译器眼里是一个更广义的概念它涵盖了几种完全不同但极易混淆的情况。相同的表达式出现在多条语句里比如先算了a * b c后面又算了一次一样的。相同的值在同一个基本块里被反复加载或反复计算哪怕写法上不完全一致。某个计算在循环里每个迭代都算一遍但操作数和结果压根不随迭代变化。某些存储和分支逻辑永远走不到甚至资源和变量赋值后从未被读取。这些情况都会产生“重复”的机器码或无效指令。编译器处理它们的策略也不一样有的靠值编号有的靠数据流分析有的靠控制流分析。所以你在源码里肉眼看不出什么问题编译优化后机器码却能瘦掉一大圈。理解这一点你才会明白为什么优化器不是单纯做文本级的“找重复”而是基于程序语义去做等价替换。这里有个很关键的理念编译器眼中的代码不是“文本”而是一张结构化的图或者带数据关系的中间表示。所谓重复是在这个抽象结构里出现了等价的运算节点或者无意义的定义而不是你肉眼看到的同名变量或者长得一样的代码块。1.2 源码到机器码之间的优化战场C编译器的工作流程大致会经过三层前端把源码翻译成初步的中间表示中端在这个中间表示上做大量与平台无关的优化后端再针对具体CPU指令集把中间表示翻译成汇编或机器码。真正的“消除重复代码”动作大多发生在中端有时在后端也会做二次精简。前端刚做完语法解析时中间表示还非常贴近源代码结构里面充满了冗余。这时候的第一件事通常是把代码转换成一种更规整的表示例如带编号的临时变量每一处运算都变成一个单独的“指令”。这样的好处是优化器可以统一看待所有的赋值、算术、分支、循环不用关心你源代码里把它们写在哪个括号里、哪个函数里。真正有意思的地方是表达式重复和值重复在中间表示里是可以用算法抓住的。比如编译器给每个中间值编一个“值编号”如果两个运算的操作数编号完全一致、运算类型也一致那它们就是同一个“公共子表达式”。这类信息很纯粹纯靠词法判断反而做不干净因为你得处理别名、常量、类型转换等复杂情况。理解了这种转换逻辑后面再看公共子表达式消除、常量传播、死代码消除就觉得顺理成章了。2. 编译器消除重复计算的核心技术2.1 公共子表达式消除常见的“重复计算”终结者公共子表达式消除CSE是消除重复代码的排头兵。它做的事情很直接如果在同一段代码路径里某个表达式已经被计算过一次并且参与计算的变量没有变化那后一次计算直接用前一次的结果就行。举个最直白的例子int calculate(int a, int b, int c) { int x a * b c; int y a * b - c; return x y; }如果不做优化这里会把a * b算两遍。开启优化后编译器会识别出两处a * b的中间结果是一致的于是只保留一次乘法后续先用这个结果再做加减。你可能觉得a * b这种一眼就能看出来但实际工程里的表达式往往更长、更复杂比如数组下标计算、多层嵌套结构体偏移这类重复很难靠人工管得住CSE却可以按图索骥。CSE的底层做法一般有两种一是基于基本块的局部值编号二是跨基本块的全局值编号。局部值编号只在一条直线代码里推动识别效率高但范围窄全局CSE则要借助数据流分析把值编号信息沿着控制流图传播。在做全局CSE时编译器还要确认中间这些路径上没有任何一个变量被重新赋值。这就是为什么在优化模式里你不会看到后面单独出现一个“编译选项叫做CSE”因为它和寄存器分配、指令调度放在一个共同框架里由中端统一驱动。值得注意的是CSE和“减少重复加载”是两个紧密关联的问题。数组元素的访问、类成员指针的读取本质上都有隐藏的加载运算CSE同样可以合并冗余访问。但这一前提是编译器能证明被读取的内存区域没被中间操作改写否则它只能老老实实重新加载。这个主题后面在讨论别名问题时还会再提。2.2 常量传播与常量折叠让计算原地蒸发的静态信息有时候重复代码不是重复表达式而是因为某个值一直不变编译器把一堆运行时的运算直接改写成常量甚至整段消失。这个技术叫常量传播和常量折叠。常量传播关心的是在某个程序点上一个变量或者一个临时值是否能够稳定地确定为一个常量。如果一条赋值语句int p 1024;之后变量 p 没有被修改那么后续所有用到 p 的地方都可以把它直接替换成常数 1024。这样做的好处显而易见许多原本依赖 p 的重复计算在编译期就能算出结果不需要生成任何运行时指令。常量折叠则在传播的基础上更进一步编译器直接把可静态求值的表达式当场算出结果。比如int func() { int width 256; int height 128; return width * height * 2; }优化后很可能只剩一个立即数65536甚至直接被替换到调用者里。这种机制在消除重复代码方面相当狠因为一旦常量传播到一个很大范围的区域很多相似的代码分支会变成同一结果后续的CSE和死代码消除会接力把它处理得更干净。这条优化链最容易碰到的问题是有没有改动程序可观察行为。如果某个表达式除以零或者数值溢出编译期算和运行时算的结果可能不同。好在C的整数运算规则比较明确优化器可以在标准允许的范围内进行折叠对于浮点数则更谨慎编译器通常会避免改变舍入行为除非你显式允许更激进的浮点规则。2.3 死代码消除删掉重复路径中的“无效劳动”死代码消除听名字就知道是把永远不会执行的代码删掉。但它在“消除重复”的主题里也扮演着重要角色因为很多时候不完整的常量传播或分支判断会产生重复的控制流块其中有些在任何情况下都被跳过这时候编译器完全可以不生成他们的代码。最典型的场景是条件编译和运行时不可达分支bool always_true(int x) { return x 0 || x 0; }在数学上这是个永真式但在源码眼里它确实有两个分支。优化器在知道了x 0 || x 0一定为真后会把假分支整块标记为不可达然后那些分支里的赋值、运算、加载就全部变成了死代码。死代码消除还会处理“写了但没人读”的值。比如一个变量被赋值但后面所有路径上都再没被读过那这条赋值就是dead store可以直接删掉。这在消除因为模板展开、宏展开而出现的冗余赋值时尤其有效。你写模板时也许生成了20行中间代码实际只用到其中4行的结果剩下的全被DCE清理掉。DCE的实现核心是两个步骤标记活跃代码然后删除未标记的语句。活跃代码一般从程序的出口点反向遍历把所有影响外部可见状态的指令打上标记剩下没标记的基本块就和“重复计算”一样被丢进回收站。这个技术和分支、常量传播联动后才能把重复代码从源头根除而不只是把重复计算合并在一起。2.4 循环不变式代码外提与更高级的冗余消除循环是重复计算的重灾区所以编译器专门有对付它的手段。最典型的是循环不变式代码外提如果某个表达式在循环体内反复出现但它的操作数在整个循环过程中都不改变编译器就会把它挪到循环之前只算一次。int fan(int* data, int n, int step, int base) { int sum 0; for (int i 0; i n; i) { sum data[i] * (base step) (base - step); } return sum; }这里(base step)和(base - step)跟循环变量 i 毫无关系每次迭代都在重复计算。编译器开启优化后会把这些表达式提升到循环外面循环体内直接使用预先算好的结果。这不是“复制一份”而是把重复执行的计算移到只执行一次的地方效果上相当于“消除了循环里的大量重复指令”。更进一步的还有一个叫部分冗余消除的概念听起来复杂本质上是在同一个控制流路径上寻找半途重复的计算。它结合了前瞻分析和后置插入能把那些即使某些分支绕过去才见到的重复表达式也处理掉。这些优化主要出现在庞大的编译器优化流水线中一般由-O2或更高级别统一开启你很少需要单独手动指定。3. 动手验证一个具体的“重复代码”观察实验3.1 用最简单的例子看 CSE 的真实效果理论讲再多不如直接动手看一眼。我通常用一个很小的函数来做实验既不依赖外部库也能快速对比优化前后的汇编指令条数。int demo(int a, int b, int c) { int first a * b c; int second a * b - c; return first second; }在不开启优化时这个函数会忠实地把a * b乘两遍再加加减减整体代码量明显偏大。开启优化后编译器会大幅减少乘法和加法指令。如果用的是主流编译器我常用的命令是“把你的编译命令加上-S和-O2再开一个-O0对比汇编”。-S的作用是直接生成汇编文件而不继续链接-O0是关闭优化保留源码逻辑两者并排放置就能看到差异。观察结果时有一个技巧直接搜汇编里的乘法指令比如mul或imul。未优化版本里大概率出现两次优化版本里往往只剩一次。这一条就足够说明CSE做成了。如果还想看得更细可以看寄存器使用优化后的汇编会明显更紧凑多余的临时变量基本消失。我建议把测试函数写得稍微复杂一点比如让a * b和其他值混合计算或者套一层循环。道理一样但演示效果更直观。因为简单例子里优化器可能直接就把没有副作用的纯计算全部常量折叠吞掉了导致你完全看不到乘法反而误以为什么都没发生。加入来自函数参数或外部变量后编译器无法在编译期算出结果才会保留真正的算术指令你才能在汇编里看到“重复计算被合并”的证据。3.2 借助优化报告看编译器的内部处理直接看汇编虽然够用但有时候代码一多人眼跟不上。好一点的编译器会提供优化报告功能把“哪个表达式被CSE识别、哪个循环被外提”这类信息输出成文本文件。这个功能真正适合检查重复代码在哪个环节被消除省得你在几千行中间表示里瞎猜。实际操作中会在编译命令里加一个开关比如-fopt-info或者-Rpass这类参数具体名字各个工具链略有差异。开报告后观察关键几行通常会看到类似“优化了公共子表达式”“优化了循环不变式”这样的提示。虽然实际报告格式会因编译器版本不同而有所变化但思路是一致的编译器认为自己在什么地方干了活都会一一列出来。对于想深入了解的人额外还有一个办法看编译后的中间表示。编译器会输出一个更规则的中间文件里面每条语句都是最细粒度的操作。对比优化前后的中间表示就能精确看到重复的运算节点被删掉或者循环体外多出了几个赋值。中间表示看起来比汇编更接近自然语言比源码更接近机器逻辑是做这类观察的黄金标准。需要提醒的是优化报告的详细程度是可以调节的。默认报告往往只输出最明显的优化项如果你想看到CSE每一次合并的记录要把报告粒度调高到“所有优化事件”级别。这样做可能让编译输出变得极其吵但对于检查特定函数极为有效。我自己的习惯是先用默认模式跑一遍确认整体优化情况再针对热点函数单独开启详细报告。3.3 关键限制为什么有些重复代码没被消除你可能会在实验中发现一个诡异的现象明明源码里的同一个表达式写了两遍优化后却仍然出现两次计算。这时候先别怀疑编译器能力多半是它无法判断两次计算之间操作数是否发生了变化。这就要说到“别名”问题。C里指针和引用满天飞编译器本质上无法精确知道所有内存位置什么时候会被写入。比如你有一个全局数组还有一个指针传入函数内部只要指针可能在两次表达式之间被某个函数调用修改哪怕你人为觉得“肯定没改”编译器也必须按照最坏情况来处理不敢冒险合并重复计算因为一旦合并就会观察到错误结果。解决这个问题常见思路是使用restrict关键字或编译器提供的“无别名”声明。你把指针标记成restrict就等于向编译器承诺这段代码里没有任何其他指针会通过它访问同一块内存。有了这个承诺编译器就不再顾虑可以安心地消除重复。这也解释了为什么纯看源码“看起来能消除但实际没有”的情况如此常见你缺少给优化器的证明材料。另一个让优化器罕见的限制是函数内部可能有外部可见的副作用比如调用了一个你未声明为纯函数的函数或者访问了volatile变量。此时编译器宁可保守也不敢跨越副作用去移动和合并代码。理解这些边界条件才能真正把握“编译器能消除什么不能消除什么”的尺度。4. 常见的坑、误区和自主可控的消除策略4.1 别过度指望编译器解决所有重复很多开发者把“消除重复代码”这件事全都甩给编译器认为只要开启-O2就高枕无忧。这是最大的误区。编译器能消除的是“计算层面的重复”也就是它能够准确证明等价的运算节点但对于“逻辑层面的重复”比如代码里出现了两段一模一样的业务处理、两组结构相同但类型不同的手工展开编译器并不能替你改写成更优雅的结构。它做的是把最终生成的机器码尽量精简而不是重构你的源码风格。举个例子两个不同路径里分别写了20行大致相同的面向对象调用这20行最终可能确实被编译器部分合并但由于代码逻辑完全不同合并后的指令数量依然可能相当庞大。如果这20行本质上是同一种规则的不同参数实例你用模板或者函数抽取后编译器反而有机会做更进一步的CSE和向量化。这说明源码层面的合理抽象和编译器的优化能力是互补的不能单靠任何一方。另外过度依赖优化器还会带来可读性的问题。有些人为了让编译器不生成重复代码写出非常奇怪的条件表达式或者极复杂的模板元编程最终机器码也许优化得不错但维护成本急剧攀升。我个人非常不建议为了“让编译器少算一遍”而牺牲代码的可读性除非你正在优化一个明确测量的热点路径。4.2 内联是新增重复代码的源头之一也是消除工具内联对重复代码的影响容易被人误解。内联本意是减少函数调用开销但函数一旦被内联到多个调用点那个函数的机器码就会在每个调用点各复制一份这是一种新增的“重复代码”。所以你可能奇怪为什么优化后二进制变大了原因之一就是大量内联。但内联本身也为重复消除提供更大的舞台。原先分布在多个函数里的公共表达式在没内联时被函数边界隔开无法跨越边界合并。内联之后这些表达式被放进同一个更大的基本块或控制流图里CSE就能看到它们并合并掉重复计算。一增一减最终效果取决于成本模型。编译器的内联决策不是盲目的它会根据函数大小、调用次数、栈开销、代码膨胀率等参数做权衡。这也是为什么你写了一个很小的访问函数编译器几乎一定会内联但一个几百行的大函数即便被调用多次它也可能拒绝内联。如果你特别想让某个函数被内联可以在函数定义处加内联提示或者在编译时调整内联参数但要清楚这会增加代码体积不一定次次划算。在这些选择上最可靠的做法是实际测量构建前后分别记录二进制尺寸、运行时间和CPU指令数。如果内联后函数跑得更快但二进制膨胀在可接受范围内那就值得如果性能变化不大那就可以放心关闭该函数的内联减少重复代码。4.3 通过源码重构降低编译器负担的几种常用手段虽然编译器是主力但有一些源码层面的习惯能大幅降低它做重复消除的复杂度。首先是尽量在循环外面计算不变量而不是指望编译器识破一切。虽然优化器确实能做循环不变式外提但当你把表达式写得极长并且牵涉到函数调用时优化器可能因为无法证明函数没有副作用而放弃。你主动把不变量放到循环外等于给优化器指了一条明路。其次是避免过度使用volatile和全局状态。volatile是专门告诉编译器“每次都要真实访问内存不准优化”一旦你用错编译器就无法对相关变量做CSE、常量传播和重新排序。很多看似莫名其妙的重复加载就是因为开发者为了某种可见性需求给变量加上了volatile。这类情况不是编译器不行而是你自己关闭了优化通道。第三是善用模板和仿函数但要注意模板展开后可能产生大量相似代码。你会看到编译器最终很可能把它们合并得很好但在一些极端情况下模板实例过多会拖慢编译速度并增加机器码重复。如果真的遇到了可以尝试把公共部分抽成非模板函数让模板只做薄薄的类型包装。这既能保住代码灵活性又不会生成重复体量过大的最终可执行文件。4.4 我的实测经验与检查流程每次接触以性能为目标的代码重构时我都有一套固定的检查流程。先把编译环境切换到一个项目专用的“优化测试配置”里面打开和线上一致的优化级别接着挑出热点函数分别编译出两个版本一个保持原样一个加入我猜测能够消除重复代码的改动最后对比二者汇编里的关键运算指令数、运行时间和二进制大小。一个很值得做的事情是编译后使用指令统计工具数一下热点区域里到底有多少条算术指令。很多反汇编工具都能输出每条指令的分布虽然数量不是性能的唯一指标但对于判断“重复计算是否真的消失”非常直观。如果指令数没有显著变化说明该处的重复代码可能有隐藏的别名或副作用阻碍优化这时我会继续查看优化报告和中间表示找到阻碍点。踩过几次坑之后我最大的体会是不要凭直觉判断重复。某些代码写两遍编译器处理得很好并不需要你操心某些代码只写一遍但因为内联和分支交织反而生成大量重复机器码。唯一可靠的判断方式就是实测。把“编译器如何消除重复代码”这个问题从抽象的原理变成你手里每一次编译对比实验你才能真正驾驭它。在项目开发中我越来越倾向于先在源码层把逻辑重复压到最低再把计算重复交给优化器。两者分工明确既不放弃可读性也不牺牲运行效率。如果你还从来没看过你自己项目的优化报告或者优化后的汇编真的建议找一个下午选最常用的函数试一试。那会比读任何理论文章都直观也会让你对自己的代码和编译器都多一分掌控感。
阅读完成 · 觉得有帮助?
咨询建站