ARTICLE DETAIL

资讯详情

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

LLVM编译器基础设施详解:从llvm-project构建到llvmpipe软件渲染

LLVM编译器基础设施详解:从llvm-project构建到llvmpipe软件渲染 做编译器的朋友应该都知道LLVM这个名字这些年几乎成了“编译器基础设施”的代名词。我第一次把 llvm-project 整个仓库拉下来编译的时候说实话是被它的体量惊到了——这哪里是一个项目分明是一整套围绕编译、链接、运行时和工具链的生态体系。很多人最初接触它是因为 Clang后来才发现 llvm-project 里藏着 lld、libc、compiler-rt、MLIR 这些宝藏子项目每个单拎出来都值得写好几篇长文。这篇文章不打算做那种“点击下一步”式的安装教程而是想从一个常年折腾 LLVM 的开发者角度把 llvm-project 到底装了什么、它的三段式架构为什么能赢、怎么从零把它构建起来、以及 llvmpipe 这种基于 LLVM 的软件渲染器是怎么回事一次讲清楚。如果你正准备入坑编译器开发或者在某个项目里被“LLVM 依赖”折腾得头疼这篇文章应该能帮你省下不少冤枉时间。1. llvm-project 到底是什么一个仓库装下了整个编译生态1.1 从“底层虚拟机”到“编译器基础设施”这个名字的来龙去脉LLVM 的全称是 Low Level Virtual Machine名字里有 Virtual Machine但现在的 LLVM 早就不做虚拟机了。2000 年 Chris Lattner 在伊利诺伊大学香槟分校开始这个项目时确实是想做一个基于静态单赋值SSA的虚拟机框架后来发现这套中间表示天生适合做编译优化干脆转向编译器基础设施。2003 年 LLVM 1.0 发布2005 年 Apple 找上门来把它用在自家工具链里后面的事情大家都知道了LLVM 从 Apple 的私有工具链扩展成整个开源社区的公共底座2011 年开始由 LLVM 基金会托管背后站着 Apple、Google、ARM、Qualcomm 这些大厂。这件事对理解 llvm-project 很重要。因为名字没改但定位早就变了所以你在网上搜 LLVM 旧资料时会经常看到“虚拟机”“JIT”相关的讨论容易看懵。现在的 LLVM本质上是一组可复用的编译器组件库它并不强制你用什么语言写前端也不强制你编译到什么 CPU 架构而是把“把高级语言翻译成机器码”这件事拆成标准流水线然后每一段都做成开放的接口。1.2 llvm-project 主仓库里都装了什么核心组件全景图把 llvm-project 拉到本地后在根目录用ls看一眼会发现一大堆一级目录。刚接触的人很容易搞混llvm、clang、lld、libc、compiler-rt、MLIR、flang、polly、lldb、openmp、libunwind还有一堆名字晦涩的目录。这里我按“编译流程”来梳理一下大家随便扫一眼能有概念就行LLVM 核心llvm/提供中间表示IR、优化 Pass、目标后端X86、ARM、RISC-V 等、MC 层、JIT 和二进制工具集llc、opt、llvm-as、llvm-dis、llvm-config 等。Clangclang/C/C/Objective-C 前端负责语法分析、语义分析和生成 LLVM IR同时暴露-cc1这种内部接口还附带clang-tidy、clang-format等开发工具。LLDlld/一个高性能链接器设计目标就是比系统默认 ld 快一个数量级同时支持 ELF、Mach-O、COFF 等格式。compiler-rtcompiler-rt/提供编译器内置函数如 128 位整数运算、如__asan_*、__ubsan_*的运行时支持库AddressSanitizer、ThreadSanitizer、UBSan 等工具的运行时都在这里。libc 与 libcabilibcxx/、libcxxabi/LLVM 自己的 C 标准库实现Apple 生态和很多新兴项目的默认选择。MLIRmlir/构建在 LLVM 之上的多层级中间表示框架用来做机器学习编译器比如 TensorFlow、IREE 的底座近年大火。LLDBlldb/基于 LLVM 的调试器模块化做得好配合 Clang 有天然的语法解析优势。其他flang 是 Fortran 前端polly 是基于多面体模型的循环优化器libunwind 是栈展开库openmp 是 OpenMP 运行时。我自己的体验是初次接触不需要把每个目录都搞懂。只要盯着两条主线走一条是语言前端到优化到后端的编译主链Clang → LLVM core → 目标指令另一条是运行时和工具链libc、compiler-rt、lld、lldb后面遇到具体问题再按图索骥查对应目录就好。1.3 为什么说 LLVM 是“编译器界的 Linux”Linux 的生态逻辑是把内核做成一个稳定的中间层上面跑各种发行版下面适配各种硬件LLVM 的生态逻辑高度类似IR 就是那个“内核 ABI”前端语言接入 IR后端架构也接入 IR整体变成一张二维矩阵。左边写一种新语言只要把前端接到 IR所有后端架构天然拿到支持右边加一个新 CPU 架构只要实现后端所有上游语言自动适配。这带来的好处被行业反复验证过。Apple 想做 Swift前端接入 LLVM起步阶段就能在自家全系芯片上跑Google 想搞 Fuchsia 和 ML 编译器直接选 MLIR 加 LLVM 兜底RISC-V 生态崛起时LLVM 后端几乎是和 GCC 后端同步做起来的。对比传统 GCC 的做法每种语言的前端和后端是高度耦合的新语言要同时接入多个后端“移植成本”明显更高。也是这个原因现代编译器团队招人时“熟悉 LLVM”几乎成了基础门槛。你不一定要知道每个 Pass 的内部实现但对 IR、优化流程、后端指令选择这几层的心智模型必须得建立起来。2. LLVM 核心架构三段式设计凭什么成为主流2.1 前端、中端、后端分离的设计哲学LLVM 把传统编译器的整体结构拆成三段前端负责把源代码变成 IR中端对 IR 做平台无关的优化后端把 IR 转成目标机器码。三段之间靠一个标准化的中间表示衔接这个设计如今看起来像是“标准答案”但在当时是有想象力的。前端的工作很杂词法分析切出 token语法分析建语法树语义分析做类型检查最后生成 IR。以 Clang 为例它的架构上还有一个 AST 层Clang 工具链里大量静态分析功能像 clang-tidy 的很多检查其实是直接作用在 AST 上的不是所有事情都得编译到 IR 再做。AST 与 IR 分离也是 Clang 和 GCC 的一个大区别这让 Clang 能做很多“源码级”的工具而不只是编译器。中端的核心是一系列 Pass。Pass 对 IR 做变换删除死代码、内联函数、循环向量化、常量传播等等。中端的铁律是“平台无关”优化策略不能假设目标 CPU 是 x86 还是 ARM因为 IR 是中间双语的约定。只有到了后端才去处理指令选择、寄存器分配、指令调度这些偏硬件的事。很多新手会问为什么不直接把源代码编译成目标码绕一大圈做三段式图什么答案是可复用性和复杂度隔离。一个语言至少需要一个前端、数十个优化 Pass、若干后端实现如果每对“语言-架构”都单独写一套编译器组合数量会爆炸三段式把复杂度锁在各层内部每一层只需要维护一套对外接口。2.2 中间表示 IRLLVM 的灵魂所在IR 有三种表现形态内存中的 C 对象表示、磁盘上的二进制位码Bitcode.bc文件、可读的文本格式.ll文件。三种形态等价可以互相转换这给调试和传递带来了巨大方便。你完全可以把前端生成的.ll打开来看逐行阅读优化前后的变化这是理解编译器行为最直观的手段。IR 的几个核心属性值得单独记住SSA 形式每个变量只被赋值一次。这种约束让数据流分析变得简单因为变量之间的依赖关系在定义时就是确定的。LLVM 用phi指令处理控制流汇合处的值选择读 IR 时看到 phi 别慌那是 SSA 的标志性产物。显式类型系统IR 里的每个值都有类型比如i32、i64、float、ptr。指针带上被指向对象的类型信息从 LLVM 15 开始指针本身不再保留 pointee 类型ptr统一表示这给别名分析和优化提供了重要线索。三类对象Module 是一个编译单元通常对应一个源文件或一个链接单元Function 对应函数BasicBlock 是无分支进入、无分支退出的基本块序列。BasicBlock 末尾通常是跳转或返回指令块内指令顺序执行。举个简单的.ll例子define i32 add_two(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }这个函数接受两个i32输出相加结果。没有全局状态、没有副作用一眼能看出它是个纯函数。这种“可读性好”不是偶然LLVM 从设计上就把 IR 当作面向开发者的一等公民这和不少内部 IR 只活在内存里、不可读的设计有本质区别。2.3 Pass 优化管线是怎么跑起来的编译优化就是一系列 Pass 按顺序对 IR 做变换。老式 Pass 管理器是整模块串行执行现在 LLVM 默认用 New Pass ManagerNPM支持按函数粒度并行处理还能更精确地管理 Pass 依赖。Pass 大致分三类分析 PassAnalysis Pass只读 IR不修改产生信息供其他 Pass 使用。比如 DominatorTreeAnalysis 构建支配树AliasAnalysis 做指针别名判断。变换 PassTransform Pass修改 IR是真正的“优化者”。比如-instcombine合并冗余指令、-gvn做全局值编号、-loop-unroll循环展开。实用 Pass负责打印、验证等辅助功能。-verify检查 IR 的正确性-print-after-all输出每轮 Pass 后的 IR调试时极其有用。优化流程不是拍脑袋定的它有一套约定俗成的 pipeline先做简单清理再做全局分析类优化再做循环相关优化最后做标量清理和后端准备。日常开发调优化时用opt工具加-passesdefaultO2能复现 Clang 在 O2 下的标准优化序列也可以手动拼一串 Pass 看它们各自做了什么。“一个 Pass 解决一个问题组合成型”这种思想对整个项目工程组织方式都有借鉴意义。3. 从源码构建 llvm-project一次完整的实操记录3.1 环境准备与版本选择构建 llvm-project 第一道坎是版本。官方每半年发一个主要版本比如 LLVM 15.0.7 是 15 系列的一个重要修复版版本之间 IR 或 API 会有变动第三方项目调用 LLVM 时极在意版本匹配。如果你只是想体验我建议直接用最新 release 分支的 tag如果你是给某个旧项目做依赖最好查对方要求的具体版本范围然后在该版本上打补丁。硬件方面完整构建 LLVM 加 Clang 需要 20GB 以上磁盘空间编译过程少说要吃 8GB 内存。我这里列一个我常用的构建环境参考项目推荐配置操作系统Ubuntu 22.04 / Debian 12 / macOS 13编译器Clang 14 或 GCC 9构建 LLVM 自身构建工具CMake 3.20Ninja 1.10Python3.8内存16GB 以上低于 8GB 建议增加 swap磁盘40GB 以上空闲空间一个很容易踩的坑构建 LLVM 的编译器会影响后续工具链的可用性。比如 GCC 8 及以下版本对 C17 支持不全构建新版 LLVM 时可能碰到“未定义的类模板”这类奇奇怪怪的错误直接把宿主编译器升到 GCC 11 或安装新 Clang 更省心。3.2 CMake 配置的关键参数CMake 配置是整个构建里信息密度最高的一步参数虽多核心就几项。我给一个最基础但能直接跑起来的配置cmake -G Ninja -S llvm-project/llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_USE_LINKERlld \ -DLLVM_CCACHE_BUILDON逐项解释一下为什么这么配-DCMAKE_BUILD_TYPERelease不开这个默认 Debug 构建的 LLVM 极其缓慢Release 才适合日常使用和性能测试。想在 LLVM 上调试开发则可以考虑RelWithDebInfo两者不冲突但先明确目的再选。-DCMAKE_C_COMPILERclangLLVM 官方最推荐用 Clang 自举构建但如果你系统里只有 GCC直接改成gcc/g也没问题。初次构建用系统已有的编译器即可不必先装一个 Clang 才能编译 Clang。-DLLVM_ENABLE_PROJECTS指定要构建的子项目。千万别贪多新手第一次就把clang;lld;libcxx;compiler-rt;mlir;flang;polly全选上只会拉长编译时间和故障排查范围。先只加 clang 和 lld后续有哪块需求再加。-DLLVM_TARGETS_TO_BUILD官方默认会构建所有支持的 CPU 后端包括一些平时用不到的架构。能根据本机架构裁剪就裁剪x86 机器只编 X86能省非常多的时间和磁盘。-DLLVM_USE_LINKERlld官方推荐使用 lld 作为链接器内存占用更少、速度更快尤其适合开发机。-DLLVM_CCACHE_BUILDON打开 ccache 后后续增量编译和前后端联调会有质的提升属于强烈建议开启的选项。配置完成后直接执行ninja不加-j让 Ninja 按 CPU 核心数并行编译。如果内存紧张可以用ninja -j 4之类手动限核避免 OOM。构建成功后所有生成的可执行文件都在build/bin/目录下可以立即./build/bin/clang --version验证。3.3 构建过程中的常见坑构建 LLVM 是一项典型的“看着简单、做起来总有意想不到问题”的工作。以下几个问题出现概率极高我几乎每次部署到新机器都会碰到至少一两个内存不足OOMNinja 默认并行度很高内存 8GB 以下很容易被编译器进程吃掉。解决办法一是加-j 4降低并行度二是临时加 swapLinux 下fallocate -l 8G /swapfile mkswap /swapfile swapon /swapfile三是用 lld 替代系统链接器能显著减少链接内存。磁盘空间爆掉Release 构建也容易超过 30GB更别说 Debug 构建。构建完想瘦身可以在 build 目录执行ninja install然后用strip去掉符号文件但最实际的做法是提前看磁盘剩余空间至少留 40GB。CMake 版本或 Ninja 版本过旧新版 LLVM 对构建工具的最低版本要求会提高报错信息有时很隐晦比如“Unsupported CMake Version”或者“Policy CMP0148 is not set”。遇到就先升级 cmake、ninja再回来重新配置。Python 解释器找不到LLVM 的测试套件和部分工具依赖 Python3。装好 Python3 并确认在 PATH 中即可默认自动探测通常没问题。如果配置或编译中途出错最有效的定位方式是翻 build 目录底部的CMakeFiles/CMakeError.log或直接看控制台最后几十行输出。不要整个重新编先看错误类型。绝大多数构建问题出在“环境不满足”而非“代码有 bug”把依赖版本核对清楚通常能减少 90% 的排查时间。4. llvmpipe 与软件渲染LLVM 的另一面4.1 llvmpipe 是什么用 CPU 跑出 GPU 的效果很多人不知道LLVM 除了“编译代码”还能在图形渲染领域干大事llvmpipe 就是典型案例。llvmpipe 是 Mesa 3D 图形库里的一个软件光栅化驱动它把 OpenGL/Vulkan 传给 GPU 的顶点着色器、片段着色器用 LLVM 的 JIT 能力实时编译成当前 CPU 的机器码然后由 CPU 模拟 GPU 的图形处理流程。它在 Mesa 的 Gallium 架构里扮演“软渲染后端”角色。你可以理解成笔记本电脑没装显卡驱动、虚拟机里没有 GPU 直通、云服务器上根本没有显卡——这些场景下 llvmpipe 就是最后的图形保底方案。以前大家觉得软件渲染就是“能显示桌面就行”性能是个笑话但 llvmpipe 一步步优化下来跑轻量 3D 应用、跑 CI 测试、跑 Wayland 合成器都已经很可用。llvmpipe 的 JIT 流程大致是拿到上层的着色器中间表示把它转成 LLVM IR然后调用 LLVM 的优化和后端能力最终生成 AVX/AVX2/NEON 等 SIMD 指令。关键点在于着色器的一个“线程”要处理多个像素/顶点llvmpipe 把它们打包成向量宽度靠 SIMD 指令并行计算把 CPU 的算力榨干。这也是为什么你会看到类似“llvmpipe (LLVM 15.0.7, 256 bits)”这样的渲染器字符串——256 bits 指的就是 SIMD 向量宽度通常对应 AVX2 的 8 个 32 位浮点或 16 个 16 位整数。4.2 LLVM 15.0.7 与 256 bits 向量化的关系例如系统渲染器输出llvmpipe (LLVM 15.0.7, 256 bits)说明当前 Mesa 的 llvmpipe 是基于 LLVM 15.0.7 构建的并且运行时检测到 CPU 支持 256 位的 SIMD 指令集。这个 256 bits 不是编译器拍脑袋定的而是 llvmpipe 在初始化时查询本机 CPU 特性CPUID后选出它能用的最宽向量宽度。不同 CPU 的向量能力不一样指令集向量宽度可用场景SSE2128 bits几乎任何 x86-64 CPUAVX256 bitsSandy Bridge 及以后大部分桌面/服务器 CPUAVX2256 bitsHaswell 及以后FMA 支持更好AVX-512512 bits部分高端服务器 CPU功耗较高NEON128 bitsARMv7/AArch64 常见AArch64 还有 SVEllvmpipe 选择 256 bits 而不是 512 bits很多时候是稳妥稳妥的选择AVX-512 在部分 CPU 上降频明显256 位能达到性能和功耗的最佳平衡而且能兼容更多机器。这就是为什么很多机器显示的是 “256 bits” 而不是“128”“512”。对 CPU 来说256 位 SIMD 意味着一条指令能同时算 8 个 float32 位 ×8软件光栅化性能上了一个大台阶。我之前在一个没有 GPU 的服务器上跑小规模 OpenGL 场景用 llvmpipe 加 256 位向量比别的纯软件实现顺滑太多。这种基于 LLVM JIT 的 SIMD 优化思路在矢量计算、数据库表达式执行、图像处理库等领域都能看到同样的套路。4.3 软件渲染的现实应用场景好几个重要场景llvmpipe 都是“隐形功臣”无头服务器与云实例云服务器不带 GPU但应用栈里可能依赖 OpenGL 做离屏渲染比如生成预览图、跑计算着色器。llvmpipe 把这种需求从“无法完成”变成了“可以运行”。CI/CD 测试跑 OpenGL/Vulkan 测试套件时最怕显卡驱动导致结果不稳定。llvmpipe 让测试环境确定性强、可重复这也是 Mesa 自己 CI 的常用手段。3D 桌面环境回退Linux 桌面在核显驱动崩溃或还没装驱动时llvmpipe 提供基本的图形界面支撑至少保证系统可用。教学与调试软件渲染便于单步跟踪、输出中间结果对想搞清楚图形管线内部原理的人来说llvmpipe 比真 GPU 还要友好。在 LLVM 15 这个版本节点llvmpipe 已经比较成熟对 OpenGL 4.5 和 Vulkan 1.x 都有不错的支持。如果哪天你执行glxinfo | grep renderer看到 llvmpipe别急着觉得“性能一定很烂”先看看 CPU 是不是有足够宽的 SIMD再想想你的应用是不是真的吃 GPU 的并行带宽。很多场景下llvmpipe 用 CPU 跑出来的成绩比十年前的中端集成显卡还好看。5. 日常开发 LLVM 的实用心得5.1 调试 LLVM 的几种方式LLVM 这种项目调试起来比普通应用工程更抽象因为你在“看一个编译器在做什么”。我常用的几个手段看 IR 变化用opt -passes... -print-after-all或 Clang 的-mllvm -print-after-all把每轮 Pass 后的 IR 打出来对比前后差异基本能定位优化问题。打印内容很多建议保存到文件后再 diff。单点调试指令选择llc -debug可以打印后端每步选择的指令流llc -mllvm -debug-onlyisel会更聚焦。sanitizerLLVM 自身开了 ASan/UBSan 构建后跑测试套件能抓到很多越界和未定义行为。官方测试都期待在 sanitizer 下全绿这才是“干净”的 LLVM 环境。统计信息-stats或-time-passes能输出各 Pass 的耗时和触发性改动的统计调性能瓶颈时很实用。一个经验不要边改边在opt里测完整优化流水线那样输出太多无迹可寻。正确姿势是先缩小到最小 IR 复现用例再用-passesfunclist...只在自己的函数上跑配合-debug-only过滤信息效率会高很多。5.2 给 LLVM 贡献代码的基础流程很多人觉得给 LLVM 提 patch 门槛高实际上它的流程现在已经很现代化了。2021 年之后 LLVM 社区从 Phabricator 迁移到了 GitHub PR普通开发者提交代码的路子更清晰找到 issue 或提案先在 discourse.llvm.org 或 GitHub issue 讨论预期设计。LLVM 社区非常看重设计先行不要闷头写几百行代码再丢 PR。Fork llvm-project 仓库新建分支编写代码和测试。LLVM 要求新功能必须附带 regression test测试文件放在对应子项目的test/目录下。用clang-format格式化代码用clang-tidy做静态检查。commit message 有规范格式首行标题用主题: 描述比如[X86] Add support for AMX-FP16 instructions.正文说明改动动机和具体行为。提交 PR 后会有人工 review也有 CI 自动跑测试。收到“request changes”别慌按意见修改、追加 commit 即可所有 review 过程的对话都会保留。合并前通常需要 rebase 到最新主分支保持提交历史清晰。大规模改动一般还要先贴 RFC 得到社区共识直接跑到 final 阶段容易被要求重做。这条流程走一遍你对 LLVM 工程的组织纪律会有很深体会。我自己第一次提 patch 时就因为测试只覆盖了 happy path 被打回一次然后才理解 LLVM 对“每个分支都要测到”的执着。5.3 常见问题速查表日常开发中有不少高频问题。我把它们整理成一个速查表方便你按图索骥问题现象可能原因推荐排查链接时报 undefined symbol函数声明与定义不一致或没加extern C检查 API 签名确认是否用了正确的 LLVM 命名空间编译时提示找不到 llvm-config环境变量问题或 LLVM 未安装用 build/bin/llvm-config 显式指定路径opt 不识别某个 Pass 名字LLVM 版本不同或 Pass 已改名/移除查opt --help-list看当前版本实际 Pass 名构建时 Ninja OOM并行度过高ninja -j 低并行数或增大 swapclang 编译不出优化效果忘加-O2或 LLVM 构建成了 Debug确认开启优化重新用 Release 构建运行 llvmpipe 渲染器不支持某 GL 特性CPU 太旧、LLVM 版本过低使用支持 AVX2 的 CPU升级 Mesa 与 LLVM改完源码后增量编译不生效ccache 缓存或生成文件依赖问题检查增量 scan必要时清理 build 目录重配每个问题背后几乎都指向同一个根源版本和构建环境不一致。LLVM 的 API 演进很快同样一段代码在 LLVM 14 和 LLVM 16 的行为可能有差异。遇到诡异问题时第一步永远是把“用的什么版本”“怎么构建的”“跑什么命令”这三件事写清楚再去查资料或提问能省下大量来回沟通的时间。最后再分享一个个人习惯不管做什么项目只要重度依赖 LLVM我都建议先花半天时间把 llvm-project 的源码目录结构认一遍。不是为了背代码而是为了培养“这个功能应该找哪个子目录”的直觉。LLVM 的代码规模虽然大但模块边界极清晰方向感建立起来后查问题、做二次开发都会顺手得多。
返回列表