拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深入LLVM:从IR原理到Pass开发与构建避坑指南

双击Mesa日志里那行llvmpipe (LLVM 15.0.7, 256 bits)的时候我正蹲在一台没有独显的服务器前排查渲染异常。说实话大多数人看到llvmpipe会直接跳过去但对我这种常年跟编译器工具链打交道的人来说这行字代表着一个相当了不起的东西——它背后就是一个完整的LLVM编译器在工作实时把你的着色器翻译成当前CPU能跑的机器码。这行日志其实就是你正在亲手使用llvm-project项目的最好证据。正好借这个机会把LLVM这个“编译器界的Linux内核”从头到尾梳理一遍。不管你是刚入门的后端开发者还是想折腾自研语言、想优化程序性能的工程师这篇文章都能帮你把llvm-project的整体脉络、如何构建、如何写第一个pass、以及那些不踩一遍就很难记住的坑一次性讲清楚。1. 先搞清楚LLVM到底解决的是什么问题1.1 传统编译器结构的死穴我入行那年写编译器课设用的是传统的三步走结构前端直接产出后端能用的中间表示然后后端生成目标代码。看起来没什么问题但一旦你想支持多种编程语言、多种CPU架构这套结构就会变成灾难。举一个非常具体的例子你写了一个C语言前端花了三个月把语法分析、语义检查、代码生成都做完编译器勉强能跑。这时候产品经理跑过来说我们要支持一套新的RISC-V芯片。你低头一看你的代码生成器跟x86的寄存器分配、指令选择全部耦合在一起想拆都拆不开。好痛苦地改三个月终于支持了RISC-V。这时候又有新需求加入一门新语言。你看着面前这两套已经绑死的代码基本就崩溃了。这个问题不是个别现象而是当年整个编译器行业的普遍痛处。GCC其实也做了一些抽象但架构上的历史包袱很重。而llvm-project从一开始就盯准了这个痛点把编译器拆成真正独立的前端、中端、后端让它们之间通过一种稳定的“中间语言”通信谁都不需要关心对方内部怎么实现。1.2 LLVM的三明治模型LLVM的核心设计可以理解成一个三明治。上面这块面包是“前端”负责把源代码变成中间表示IRIntermediate Representation。Clang就是LLVM官方的C/C/Objective-C前端Rust的rustc前端在早期也借用过LLVM的IR。中间这块肉是“优化器”它不关心你的代码来自C还是Rust只对IR做各种变换。你在编译时开启的-O2、-O3优化就是这一层在工作。下面这块面包是“后端”负责把优化好的IR生成目标平台的机器码。x86、ARM、RISC-V、GPU、还有llvmpipe这种软件渲染环境都有对应的后端实现。这个模型最迷人的地方在于只要语言前端产出的IR是合法的它就能自动享受到所有优化和后端支持。你想支持一门新语言只需写前端你想支持一款新芯片只需写一个后端。两边的工作量完全解耦互不干扰。1.3 为什么说它是基础设施llvm-project不是一个单一编译器而是一整套生态Clang工业级C/C编译器前端错误提示比GCC友好得多LLVM核心库IR、优化pass、目标描述、代码生成LLD一个速度极快的链接器libcC标准库实现compiler-rt编译器运行时库MLIR用于构建编译器与领域特定语言的基础设施很新也很有野心LLDB基于LLVM的调试器BOLT针对二进制文件的优化工具这套东西已经不是单纯“编译器”的概念而是编译器领域的基础设施工具箱。你现在手机上跑的不少代码、游戏主机上的优化、甚至云厂商硬件加速器上的编译背后很可能都有LLVM的身影。2. 核心机制拆解IR、Pass与后端2.1 IR编译器世界的通用语LLVM IR是这门技术的灵魂。它是一种类似汇编、但比汇编更高级的“静态单赋值”SSAStatic Single Assignment形式语言。SPA这个概念说白了就是每个变量只被赋值一次。你读代码时遇到的像%1 add i32 %a, %b这种写法就是在描述一个值只定义了一次、后续只能被引用。这个约束看着简单但它让优化器做分析时方便极了——数据流关系一目了然没有“这个变量现在是谁、还能不能改”这类状态性问题。IR还有一个关键特性它分为三种形式但逻辑上等价。在内存中的表示C对象图人类可读的文本形式.ll文件一般用于调试紧凑的二进制位码形式.bc文件用于跨模块传播你在命令行里用clang -S -emit-llvm拿到的那一堆?ll文本就是编绎器处理到中端时的“半成品”。很多深入优化问题、甚至编译器bug最后都要靠直接看IR来定位。2.2 Pass框架优化是如何“流水线化”的IR本身不会自己变快真正让代码产生质变的是跑在IR上的那些优化pass。每个pass干一件事比如Dead Code Elimination死代码消除删掉算完就丢、没有副作用的值Loop Unrolling循环展开减少循环控制开销Inlining内联把函数调用直接替换成函数体GVN全局值编号消除完全重复的计算整个优化过程是一长串pass排队执行类似流水线作业。每个pass吃进一个IR模块输出一个“更优化”的IR模块。你问为什么-O2比-O1生成代码更快本质上就是-O2多跑了不少额外pass的结果。传统的GCC也有类似概念但LLVM的pass框架设计得更干净、更容易扩展。写一个自定义pass插进Pass Pipeline里就能在编译中瞎搞——这句话的意思是第三方研究者、企业工程师可以非常方便地做定制化优化这也是为什么很多芯片厂商都选了LLVM作为基础。芯片厂商拿到一个新的指令集扩展比如某种矩阵加速指令写一个pass专门识别特定计算pattern、改写IR来使用新指令剩下的编译流程完全不用动。2.3 后端从IR到机器码的惊险一跳后端是LLVM里最复杂、最“硬核”的部分。它干的事情包括把IR指令跟目标指令集做匹配与选择Instruction Selection把无限制的虚拟寄存器一一分配到真实寄存器上Register Allocation调整指令顺序让流水线、缓存表现更好Instruction Scheduling把不同目标文件的符号拼接到最终可执行文件这就要交给LLD了这里面有个很经典的问题叫“指令选择的组合爆炸”。同一个语义可以用完全不同的指令序列实现怎么选最划算LLVM的SelectionDAG和后续的GlobalISel框架都在解决这个问题。为什么有些芯片新架构出来后LLVM支持速度比其他编译器快本质上就是后端框架够模块化大家复用一套TableGen描述文件用DSL写出指令模式就能自动生成匹配代码。顺带说一句llvmpipe正是LLVM后端能力在图形领域的体现。它把图形着色器编译成当前CPU的SIMD指令比如AVX2就是日志里那句“256 bits”的来历让没有独立显卡的机器也能跑出可用的OpenGL/Vulkan性能。3. 亲手构建一个可用的llvm-project3.1 源码获取与版本选择构建是整个LLVM生态里劝退率最高的一步。如果你用官方仓库默认配置直接干往往会出现“编译了俩小时、硬盘100G没了、最后内存溢出直接失败”的人间惨剧。我建议按下面的思路来版本选择别追最新main分支选一份release版。我在生产环境长期用15.x和17.x这两个版本线。15.0.7这个版本其实相当稳定很多Linux发行版和Mesa项目至今仍在引用它。获取方式用git clone --depth 1 -b llvmorg-15.0.7 https://github.com/llvm/llvm-project.git只拉单分支、指定tag避免整个仓库历史把磁盘撑爆。3.2 CMake配置里那些要命的参数LLVM用CMake构建而CMake参数选得对不对直接决定你这次构建是“半小时搞定”还是“半天等一个坏结果”。我当前用的构建参数大致是这样cmake -S llvm-project/llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_ENABLE_RUNTIMEScompiler-rt;libcxx;libcxxabi \ -DLLVM_USE_LINKERlld \ -DLLVM_PARALLEL_LINK_JOBS2 \ -DLLVM_CCACHE_BUILDON \ -G Ninja这里每个参数都是一个坑值得单独说说CMAKE_BUILD_TYPERelease编译LLVM自身时也开优化构建产物小、跑起来快。用Debug模式构建LLVM会让pass跑起来慢几倍甚至几十倍调试LLVM自身时才需要。LLVM_TARGETS_TO_BUILDX86默认会为全平台生成后端代码开English全家桶模式很费时间。你只想在x86上跑就只写X86。想用llvmpipe那种多后端能力可以把ARM、AArch64、RISCV都加进去但务必知道自己为什么要加。LLVM_ENABLE_PROJECTS这是决定先编译哪些子项目的关键。只要编译器就开clang要链接器就加lld不需要一次性全开。LLVM 16之后有些runtime组件要从RUNTIMES里面选这个和PROJECTS的分工曾是无数人构建失败的来源。LLVM_USE_LINKERlld这一步非常反直觉但也非常有效——用LLVM自己的lld来链接LLVM自身。相比系统自带的GNU ld链接速度能快好几倍。前提是你得先有一个能用的lld所以我一般第一遍先装发行版自带的lld/bintuils再把它指接给构建流程。LLVM_PARALLEL_LINK_JOBS2链接阶段每个链接任务都是内存大户默认并行度太高很容易把16G内存直接吃到swap。限制成2个并行链接是我经历了几次OOM之后得出的稳妥值。LLVM_CCACHE_BUILDON用ccache缓存C/C编译产物。你在调CMake选项、改一两个源码文件后重新构建缓存能帮你省掉大量重复编译。强烈建议装一个ccache谁用谁知道。3.3 构建执行与验证cmake --build build -j $(nproc)这里有个技巧如果机器内存不够建议先跑-j 4别贪多。构建进度到99%然后因为OOM失败那才是最崩溃的。构建完成后验证一下build/bin/clang --version看到类似clang version 15.0.7的输出说明你已经有一份属于自己的LLVM工具链了。我自己的经验是在整个构建过程中最值得等赖的是“第一次链接”。注意如果你是8G内存的小机器还得额外加一行-DLLVM_USE_SPLIT_DWARFOn或干脆把-j调到2。别小看这些调整它们决定了你是优雅等待还是一路踩雷。4. 实战写一个最简单却五脏俱全的LLVM Pass4.1 为什么要自己写pass很多人学LLVM都卡在这一步——不知道写一个pass能干嘛。我举个实际工作例子你在维护一个大型C服务发现某个热函数里有一批形如a * 2 1的计算。你怀疑某些写得很糟糕的模板实例生成了大量重复计算。与其改代码不如写一个pass在优化阶段把这类模式识别出来、替换成移位和加法组合。这种优化思路传统上都是编译器开发者干的活但有了LLVM的pass机制你做业务开发的也能参与。4.2 环境准备先用第3节构建出来的工具链写一个最小的“模块pass”。LLVM目前主推的是New Pass Manager我们就直接用新架构来写。#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct DemoPass : public PassInfoMixinDemoPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Add) { errs() Found add in function: F.getName() \n; } } } } return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, DemoPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) - bool { if (Name demo-pass) { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这段代码的意图非常朴素遍历每个函数的每个指令如果发现一条二元加法指令Add就打一行日志。但通过这个小骨架你可以学会new pass manager里最关键的三个概念PassInfoMixinDemoPass新pass manager中所有可运行pass都必须混入这个模板run(Function F, FunctionAnalysisManager AM)pass的入口输入一个IR单元输出分析结果llvmGetPassPluginInfo动态注册接口让优化器知道这个插件的名字叫demo-pass4.3 编译并加载pass写一个CMakeLists.txt来构建它cmake_minimum_required(VERSION 3.20) project(DemoPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) add_library(DemoPass MODULE DemoPass.cpp) target_link_libraries(DemoPass PRIVATE LLVM) set_target_properties(DemoPass PROPERTIES PREFIX LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/lib)构建时CMake需要知道你的LLVM在哪cmake -S . -B build -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm cmake --build build然后找一个测试C文件比如test.cint foo(int x, int y) { return x y; }先把它编成IR再在opt中加载我们的插件跑一遍/path/to/build/bin/clang -O0 -S -emit-llvm test.c -o test.ll /path/to/build/bin/opt -load-pass-pluginbuild/lib/DemoPass.so -passesdemo-pass test.ll -S -o /dev/null如果一切顺利你会在终端看到Found add in function: foo。那一刻的成就感真的不亚于第一次跑通Hello World。4.4 写pass时容易踩的坑忘记返回PreservedAnalyses::all()如果你的pass只读不写一定要返回all()告诉优化器“所有分析都还新鲜”否则优化器可能因为无效分析而多跑多少遍。反过来如果你真的改写了IR就得仔细思考哪些分析失效了乱返回all()会产出错误代码。函数内遍历指令时不要随意插入删除在range-based for里直接eraseFromParent()会导致迭代器失效。正确做法是先把要处理的指令收集到一个SmallVector里遍历完再统一改。插件API版本必须跟运行时LLVM版本匹配用15.0.7的库去加载一个17.0写入的插件基本直接报版本冲突。所以自己构建的LLVM和写pass的环境一定要用同一棵树。5. 从构建到运行那些高频坑与排查清单5.1 构建失败的经典场景拿我这两年回答过最多的问题来整理一下基本都是这几类现象常见原因解决方案Ninja进度卡在99%后报错退出链接并行度太高内存耗尽设置LLVM_PARALLEL_LINK_JOBS1或2降级-jCMake提示找不到Zlib/Terminfo等缺少系统依赖Debian/Ubuntu下sudo apt install zlib1g-dev libtinfo-devccache缓存未命中构建极慢ccache版本古老或没开CMAKE_CXX_COMPILER_LAUNCHER用新版ccache并在CMake中让编译器启动器生效自定义pass编译报大量模板错误没有正确链接LLVM头文件版本确认使用同一个构建树里的llvm-configclang刚编译完就coredump用了Debug模式构建LLVM自身改成Release必要时用RelWithDebInfo5.2 运行期遇到IR层面问题的排查思路有一个经验我觉得特别值得分享当你怀疑编译器优化产生错误结果时不要直接去看汇编而是先看优化后的IR。操作流程是clang -O2 -S -emit-llvm foo.c -o foo_opt.ll然后对着foo_opt.ll逐块找哪里跟你的预期语义不一致。这个办法在排查“为什么release版跑不出正确结果”的时候极其有效远比直接盯编译后的机器码轻松。另外一个技巧是使用opt -print-after-all打印每个pass执行完毕之后的IR变化。不过这个输出的信息量非常大建议配合-filter-print-funcs你的函数名参数只看你关心的那一个函数。5.3 llvmpipe日志里能读出什么回到开头的llvmpipe。日志里那行“LLVM 15.0.7, 256 bits”其实包含三个关键信息llvmpipe当前图形渲染走的是Mesa的软件渲染路径没有硬件GPU参与LLVM 15.0.7Mesa内部调用的LLVM运行时版本256 bitsLLVM后端为当前CPU选择的向量宽度。如果你的CPU支持AVX2就是256位的YMM寄存器如果只支持SSE2这里大概率显示出128 bits当你看到256 bits却感觉3D性能很差时不一定是LLVM的问题可能瓶颈根本不在编译生成指令的效率上而在软件光栅化本身的像素填充速度。如果你确认着色器编译慢可以翻看Mesa里LP_NATIVE_VECTOR_WIDTH这类环境变量来微调向量宽度。这类软件栈联调的问题往往需要你同时理解图形栈和编译器后端两个层面这也是LLVM知识在真实场景中非常值钱的地方。6. 再往前走一步如何继续深入llvm-project6.1 路线图从使用者到贡献者如果你看完这篇文章打算真正深入LLVM我建议顺着下面这条路走会用熟练使用clang、opt、llc能阅读.ll文件能看懂简单的pass输出。能改能写自己独立的opt插件pass实现简单的函数内优化。能改核心理解Instruction Selection、Register Allocation的基本流程能修改llvm/lib/Target下面的TableGen描述文件。能融入社区在llvm-project的GitHub上找“good first issue”从修文档、改测试开始逐步接触核心评审流程。LLVM社区的代码评审非常严格一个patch被来回改十几遍都很正常但这个过程会飞速提高你写大型C工程的功底。6.2 值得收藏的官方文档和调试工具先说文档llvm.org/docs/里的WritingAnLLVMPass、ProgrammersManual是必读的。入门期的我一边读一边动手敲示例大概花了两周才算真正“读懂”。然后就是llvm.org/doxygen/这个你写代码的时候会反复查。再说工具日常调试LLVM时这几个命令几乎不离手# 看一个pass在整个流水线中的位置 opt -debug-pass-manager test.ll -passesdefaultO2 -S -o /dev/null # 查看IR所有基本块和指令的统计信息 opt -analyze -domtree test.ll -S -o /dev/null # 列出所有已注册的命令行选项 opt --help-list如果你碰巧要改后端llc -marchx86 -mcpuhelp可以快速列出当前支持的CPU特性集合。这比翻源码找特征列表快得多。6.3 一个真实的实战案例用pass做性能优化最后聊一个我实际经手的案例。有次做一个图像处理库的优化热点循环里有一堆带符号整数的饱和运算。手写SIMD太痛苦业务代码又不想大改。我的做法是写了一个函数级pass专门在IR层面识别add后紧跟icmp slt再跟select的饱和运算pattern把它替换成一条自定义intrinsic函数。这个intrinsic在后续后端pass里直接被映射到对应的SIMD饱和指令上。最终效果是那个函数的整体耗时下降了约三成而业务代码几乎零改动。这就是LLVM生态的魅力——你可以把对硬件的理解、对算法的洞察全部沉淀成一层层可复用的编译器基础设施而不是散落在业务代码里的各种hack。我个人在实际操作中的体会是LLVM的门槛确实不像普通应用开发那样低但它的回报也远超一般技术栈。你花三个月时间搞懂IR、pass和后端的关系收获的不只是“会编译”这个能力更重要的是对整个程序如何被“制造”出来这件事的完整认知。这份认知在以后的很多性能优化和疑难问题排查中都会在关键时刻给你带来一种“从底层看穿问题”的直觉。如果真的想学别犹豫从今天开始在你的机器上拉一份llvm-project跑起来吧。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门