ARTICLE DETAIL

资讯详情

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

LLVM编译器优化:从IR到pass的完整实践指南

LLVM编译器优化:从IR到pass的完整实践指南 做编译器优化这件事最怕的不是优化不生效而是你根本不知道它为什么不生效。LLVM 这套基础设施几乎撑起了当代编译世界的小半边天Clang 用它Rust 的前端也构建在它上面iOS 的 Swift 工具链更不用说。而这个标题里的“LLVM - 编译器优化一”正好是我认为最值得写的话题。我见过太多同学兴致勃勃把一份 C/C 代码喂给 Clang加个-O2就觉得万事大吉过了几天性能不对也不知道该从哪里下手查。这篇博文我尽量把“LLVM 优化器是个什么结构”“优化到底在优化什么”“常见优化 pass 各有什么脾气”“你自己怎么用opt完整观察一遍优化过程”一次讲透。哪怕你之前完全没接触过编译器源码只写过一点 C 语言也应该能跟着我的思路把整条链路理顺。因为 LLVM 的优化器并不是什么天书它本质是把语言无关的中间表示一股脑丢进一系列 pass一个一个改改到顺眼为止。1. 先搞清楚 LLVM 优化器到底在干什么LLVM 的架构听上去高大上实际上就是“一次中间表示多重后端复用”的典型。前端解析源代码生成 IR优化器只针对 IR 做文章后端再接住优化后的 IR生成目标平台指令。这三层里优化器是最有意思的一层因为它既不关心语法糖也不关心寄存器命名它只盯住一个抽象层次LLVM IR。1.1 从源码到目标代码LLVM 的三段式管线你可以把 LLVM 想成一家餐饮中央厨房。前端Clang是采购和洗菜负责把 C/C 源码经过词法分析、语法分析、语义检查后变成一份统一的“净菜”——也就是 IR。优化器是中央厨房里的厨师团队按照菜品要求切配、调味、去除多余水分最终把净菜炒成半成品。后端则是装盘出餐的队伍把这盘半成品按不同餐厅的要求x86、ARM、RISC-V做成不同的餐品。这种三段式架构最大的好处就是优化过程不需要重复研发。每来一种新语言只要写一个能产出 LLVM IR 的前端就能享受到整套优化器和众多后端。每来一种新芯片只要把后端生成的部分对接好就自动支持了所有前端语言。正因为如此你也会看到不少新语言比如 Rust、Swift、Julia都愿意长在 LLVM 这个底座上而不是从零开始折腾一整套编译后端。在技术细节上这段流水线对应到命令行工具大概是这样的clang -S -emit-llvm foo.c拿到可读的 LLVM IR 文本。opt -passesxxx foo.ll -o foo.bc对 IR 执行一系列优化 pass。llc foo.bc -o foo.s把优化后的 IR 变成目标平台的汇编。clang foo.s -o foo实际链接、生成可执行文件。搞清楚这个链路你后面排查问题时会轻松很多。因为“编译器优化不生效”这种问题八成不是后端或前端出错而是你根本没搞清楚优化器对这份 IR 做过什么、没做过什么。1.2 LLVM IR 到底长什么样静态单赋值与人类可读性我每次给新同事讲 LLVM IR都会强调一句话它看起来像一种奇葩的、带无穷临时变量的汇编语言但它比汇编好读多了。一个最直观的特征是alloca满天飞函数入口动不动就开一堆栈空间然后用load和store进进出出。我常打的比方是前端生成的 IR 像一位过于谨慎的会计每笔流水都记在小本本上生怕自己失误。另一个重要概念是 SSA静态单赋值Static Single Assignment。这意味着每个变量只能被赋值一次后续如果变量被更新必须生成一个带编号的新版本。SSA 对优化器极其友好因为它天然把“数据流”变成了“图的边”分析依赖关系时不需要到处猜某个寄存器会不会变。但人类写代码时显然不会这么写int foo(int a, int b) { int x a b; x x * 2; return x; }这样的 C 代码落到 LLVM IR 的早期阶段往往会变成先用alloca为x分配一块栈内存然后每次用store存新值、用load取旧值。整个过程啰嗦、低效但非常容易生成、也非常稳定。这就给优化器留出了巨大的发挥空间——把这种“又臭又长”的中间码一步步收拾得干净利落。2. 优化器的工作单元pass 机制与运行管线如果只让你记住 LLVM 优化器里的一个核心词那就记pass。所谓 pass翻译成大白话就是“一遍遍处理”。你写一个函数把它交给几十个 pass 依次处理每个 pass 只负责一种特定模式的改头换面最终合起来产生惊人的优化效果。这种设计哲学来自经典的编译教科书——宁可做一系列小而准的变换也不做一个无所不能的巨型优化。2.1 pass 本质每个优化都是一次 IR 重写从实现者的角度看一个 pass 就是一段遍历 IR、发现某种模式并进行重写的代码。比如Dead Code Elimination死代码消除会找“算了也不会被别人用”的指令并删掉Reassociate会把数学表达式重新关联排序以生成常量折叠机会SimplifyCFG则会化简控制流图。每一个 pass 的工作范围都不大但它们互相配合、反复轮转最后效果拔群。这就像你家里收拾房间不会有人一步到位把整个屋子清理成样板间而是先扔垃圾、再擦桌子、然后拖地甚至扫地可能还得拖两遍。LLVM 的优化 pipeline 也这样经常是几个 pass 在不同阶段反复执行因为每个 pass 做完之后又会暴露出新的可优化空间。比如某段代码先被常量传播 pass 发现了常量值随后死代码消除 pass 才可以把后面“算了个寂寞”的指令删掉。少了任何一环最终 IR 都不会那么干净。在实际使用中我们不会手动去敲几十个 pass。LLVM 提供了优化等级-O0、-O1、-O2、-O3、-Os等每个等级对应一套固定的 pass 顺序。简单说优化等级越高会有越多的 pass 被激活其中不少 pass 还会以更激进的方式重写代码。但高优化等级不代表永远更好它可能增加编译时间也可能生成调试信息更难理解的代码甚至个别情况下会碰上一个 pass 的组合让程序出现行为变化这是后话。2.2 PassManager 与优化等级别被 -O3 迷惑Pass 不是随便排队的靠的是 PassManager 来统一调度。这里的核心思想是“管线化”在公开文档里可以查到从-O1到-O3以及-Oz的 pass 组合不过会随 LLVM 版本演进小幅变化。我只说我观察到的规律-O0基本不优化只做最基础的合法性整理产出适合调试的 IR。-O1启用轻量级优化控制流清理、简单合并、ADCE 等适合编译速度和代码体积更敏感的场景。-O2默认级别在编译时间和优化效果之间取得较好平衡大多数常见优化都被激活。-O3在-O2基础上追加更多激进变换比如更积极的内联、循环展开、向量化等。-Os/-Oz以代码体积为优先比-O2更克制动用极端优化。实际工作中不少项目直接给-O3但如果你用工具链做了很多安全加固或者程序逻辑对浮点计算敏感-O3的激进变换确实偶尔会带来行为差异。我自己遇到最多的不是 bug而是“为什么-O3没比-O2快”的疑问——这不奇怪因为现代 CPU 的瓶颈不总在指令数量可能在访存模式、分支预测、缓存命中率。优化器只能在 IR 层面做一些相对通用的变换它不理解你的特定业务数据分布。3. 最值得优先掌握的几个优化 pass接下来的内容我不打算把几十个 pass 全列一遍那既无聊也没必要。我更想选几个最常见、理解成本最低、实战价值最大的 pass讲清楚它们为什么存在、能解决什么问题。等你跑通了这十几个再去看 LLVM 官方 pass 列表就会豁然开朗。3.1 mem2reg从“内存地狱”到 SSA 形态的关键一步如果你直接看 Clang 吐出来的未优化 IR会怀疑人生为什么一个简单的int x 1; x x 2; return x;会变成alloca 一堆loadstore答案是前端为了保持语义、方便代码生成用内存地址统一表达局部变量但这个阶段的 IR 效率极低。mem2reg就是用来把局部变量从内存形式提升为 SSA 寄存器形式的 pass。怎么提编译器会先判断某个alloca指令的地址有没有逃逸。比如你对局部变量取地址然后把地址传给另一个函数那这种alloca就不能随便动因为外部随时可能通过指针偷偷修改它编译器无法安全地把它改成寄存器。对于没逃逸的局部变量mem2reg就能把load改成直接读 SSA 值把store改成新版本 SSA 值甚至会根据分支情况在汇合点插入phi节点。这也是我建议初学者第一个从源码层面去读的 pass因为逻辑非常清晰先收集alloca再检查使用情况再重写所有读写位置。读懂mem2reg后“什么是 phi 节点”“什么是支配边界”这些概念都从纸面活了过来你再去理解后续所有数据流优化就都有了底子。3.2 instcombine、constprop 与 simplifycfg小而美的万能组合我把这几个 pass 放一起讲是因为它们在实际优化管线里的定位很像一个“保洁小组”不是大刀阔斧重构某个模块而是不断清除局部区域的冗余与垃圾让后续 pass 看得更清。instcombine是一系列指令模式匹配与重写的集合比如x 0变成x、(x 1) 2变成x 3、a 0变成 0等等。constprop负责常量传播只要变量的值被证明是常量就把后续使用位置直接用常量替换。纯靠它有时看不出惊天动地的性能提升但它不断为其它 pass“创造机会”。死代码消除随后就能把一些因为常量传播变“死”的指令清理掉而这又会把更多模式暴露给 instcombine如此不断循环。simplifycfg则负责对整个函数控制流图做“整容”删掉冗余分支、合并同样的块、消解为完全跳转而设的空壳块。这三个 pass 往往同时出现实际效果最直观的例子就是int compute(int a) { int b a 1; int c b * 0; return c 42; }未优化 IR 会极其冗长地展示每一步。而经过-O1后它大概率被直接简化成return 42。为什么因为b * 0一定是 00 42是 42而a在这个函数里完全不影响结果。编译器不需要运行时执行它在 IR 层面就把逻辑推平了。3.3 GVN 与 LICM跳脱单个表达式之外的全局级优化如果前面的 pass 处理的是“局部垃圾”那GVN全局值编号和LICM循环不变代码外提就是稍微动点脑子的优化。GVN 的核心思路是“同一个值不要算两遍”。假设代码里有x a * b; y a * b;只要编译器能证明在两个位置中间的a、b没变它就敢把y直接替换成读取x的结果。而 LICM 的思路更明确循环里如果有一段计算和循环变量无关就应该把它挪到循环外面。比如for (int i 0; i n; i) { a[i] x * y z; }x * y z明明可以在循环开始前算好结果每个迭代都重复计算一次。LICM 会把这段不变式代码外提于是循环体里只保留一个存数组元素的操作。对超大循环来说这种提升往往非常可观。不过这两个 pass 的触发前提都很重要编译器必须能够“证明”代码能被安全移动。风险来自哪里如果x * y z的计算本身会触发未定义行为如整数溢出视为 UB 倒还好说但如果你开了 trap 标志行为就不同了或者中间涉及函数调用可能修改全局状态编译器就宁可保守。这也是为什么代码里滥用全局变量、滥用指针别名会让很多优化直接失效。你想让编译器帮你优化自己就别给它制造“数学证明”的困难。4. 用 opt 亲手观察一次优化全过程光说不练假把式。我想带你完整跑一遍opt流程让你亲眼看看一份代码是怎么从“啰嗦的中间表示”被改写成“很顺眼的中间表示”的。全程只需要装一个 LLVM/Clang 工具链任何主流 Linux 发行版或者 macOS 上都能通过包管理器一把装好。4.1 准备实验代码并生成未优化的 IR先准备一个小 C 文件我建议内容取一个“能看出变化但不过于复杂”的函数// demo.c int compute(int a, int b) { int x a b; int y x * 2; int z y a; return z - x; }然后执行clang -S -emit-llvm demo.c -o demo.ll这时候你打开demo.ll看到的基本上就是前端刚产出的样子有alloca、有load、有store整体非常啰嗦。我第一次看这种 IR 时脑子里只有一个念头这也能优化得回来随后你执行opt -S -passesmem2reg demo.ll -o demo-mem2reg.ll这时再看 IRalloca就不见了取而代之的是一组 SSA 临时寄存器。你会发现x和y这些变量的读写都被直接替换成了寄存器操作量级瞬间就降下来了。这一步是优化管线至关重要的一环把“内存视图”变成“数据流图”。4.2 逐级执行优化 pass对比每个阶段的 IR 变化接着我们在 mem2reg 基础上继续跑一点常规优化一条命令就能串起来opt -S -passesdefaultO2 demo.ll -o demo-O2.ll这次会看到更极端的化简结果。因为我上面的z - x与前面变量之间的关系可以大幅消除。实际生成的结果在不同 LLVM 版本里可能略有差异但大致上compute函数已经被化简成极少数几条算术指令。如果你用一个语义更直接的例子比如把compute改成int compute2(int a) { int b a * 2; int c b - a; return c 1; }经过defaultO2之后运算过程会被规约成像ret i32 add i32 %a, 1这样直接的 IR。为什么a*2-a就是a再加 1。编译器在 IR 层面轻松完成了代数化简但这个过程不是靠某一次魔法而是 mem2reg、instcombine、gvn、simplifycfg 等多轮 pass 配合才做到的。看 IR 的过程中有几个命令行小技巧很实用opt -S -passesprintloops demo.ll打印循环结构。opt -S -passesprintdomtree demo.ll打印支配树。opt -S -passesdebugify ...用于调试 pass 本身适合以后读源码时玩。opt -S -passesinstcombine,constprop,dce demo.ll手动控制 pass 组合看每一步变化。一定要养成“分步跑一遍”的习惯。遇到性能问题先看中间 IR再倒推是哪个 pass 没把机会抓住这比在汇编层猜测快很多。5. 实战中高频踩坑与排查速查这一节我整理了几类我实际遇到的、论坛里也频繁出现的问题。尤其是“llvm error: io failure on output stream: input/output error”这个报错不少人一头雾水。我把自己查问题时的思路说清楚。5.1 “io failure on output stream”这类报错要怎么解先别高级化理解这个报错多半不是你代码的问题而是环境的输出链路出了岔子。最常见的原因包括磁盘满了或者当前目录不可写。你通过管道把opt的输出接到另一个工具下游工具提前退出了导致上游写管道时拿到 EPIPE。文件系统权限不对比如编译缓存目录只读。跨文件系统移动临时文件时的 I/O 错误。我实际遇到最多的是第二种。比如很多人会习惯性这么干opt -S -passesdefaultO2 demo.ll -o /dev/stdout | head -n 20head看够 20 行就退出opt继续写标准输出就会遇到管道关闭报出io failure。这不是 LLVM 坏了而是你的管道预期没配对。碰到这类报错最省事的排查路径先把输出改成普通文件同时检查磁盘空间。5.2 预编译 LLVM 与自编译版本到底怎么选另一个热词是“有没有预编译的 LLVM”。我的建议很明确能装发行版预编译包就先用别再纠结源码编译。Ubuntu/Debian 上你可以直接用apt install llvm clangmacOS 上可以用 Homebrew 装llvm包甚至 Windows 上也有官方构建的二进制包。绝大多数场景预编译版完全够用而且省掉至少一小时的编译时间。什么时候你需要自编译常见情况有两种。一是你需要调试 LLVM 自身或者修改源码后看 pass 行为这时必须自己从代码构建一个 Debug/Assertion 版本。二是你的业务对某个特定版本、特定 Target 的裁剪有要求你也得专门构建。但无论哪种情况我都不建议一开始就上全套源码构建——先用包管理的版本把概念跑通再决定要不要折腾。还有个小细节如果你要用opt跑自定义 pass而 pass 是用new PassManager接口写的官方不同主要版本的 pass 命令行语法有变化。比如 LLVM 17 前后-passes...是主流写法老的-mem2reg样式在新版本里已经不推荐甚至不可用了。你完全可以先在默认版本上跑通再去对照官方迁移指南跟上版本演进。5.3 如何快速定位某个优化是否真正生效这个问题经常出现在“我开了-O2但性能没有想象中提升”的疑问里。我推荐三步定位法第一步生成优化前与优化后的 IR做对比。重点看循环内部是否还有重复计算、数组访问是否被改成更高效的指令序列。第二步用time或 perf 工具看热点是否在你的目标函数里。有时候优化确实生效了但热点压根不在这个函数那本质是性能模型选错了目标。第三步看汇编。如果 IR 层看不出来直接llc生成汇编检查指令数、访存与分支情况。我自己常用一个土办法在源代码里故意给目标函数加一点可以让编译器发挥代数化简的空间如果优化后汇编明显变短说明优化管线是通的如果变短幅度不合理再进一步查中间 IR十有八九是前端的某个volatile、外部函数边界或指针别名限制住了优化。另一个很值得掌握的工具是-Rpass系列参数。Clang 支持将优化日志打印出来比如clang -O2 -Rpassloop-vectorize -Rpass-missedloop-vectorize demo.c它会告诉你某个循环有没有被成功向量化、为什么没被向量化。这对分析高性能计算中的循环优化尤其有用。从整体上看LLVM 优化器的学习曲线确实比较陡但它的设计非常规整只要你愿意把自己代进“中间表示清洗工”的角色把每个 pass 看成一道独立的工序接下来的一切都会顺理成章。我个人的习惯是每学一个 pass就写一个故意能让它“秀一把”的 C 函数编译成 IR 后看它如何变化。这比直接背几十个接口定义有效得多。从一个最小例子开始你会逐渐发现所谓编译优化并不是玄学而是一系列可观察、可解释、可操作的确定性变换。
返回列表