Unity Burst与LLVM底层原理:让C#代码性能媲美C++的关键 📅 发布时间:2026/9/9 7:38:09 👁 浏览次数: 在Unity里做高性能游戏开发尤其当你开始碰ECS、Job System或者发现同逻辑的代码在C里能跑几百万次计算而C#里却卡成PPT时最终都会撞上一个绕不开的底层家伙——LLVM。而Unity的Burst Compiler本质就是把C#的一部分代码通过LLVM这个编译器框架变成真正贴近CPU的机器码。这篇文章我会从LLVM的核心设计讲起再拆开Burst的工作流程最后结合我实际踩过的坑聊聊怎么写代码才能让Burst发挥全力。适合谁看想搞明白Burst到底“快在哪”的Unity开发者以及已经用了Burst但是遇到优化不生效、疑难报错想真正理解背后原因的人。只要你有C#基础哪怕没写过C也能跟着走一遍这套底层逻辑。1. LLVM到底是干什么的先把它当编译器“操作系统”来理解1.1 LLVM不是“一个编译器”而是一整套编译器基础设施很多人在接触LLVM之前以为它跟GCC一样是某个具体的编译器输入C代码输出可执行文件。其实LLVM的定位更底层它是一个用来构建编译器的“半成品工厂”。你可以把它想象成一套模块化的发动机零件库GCC是直接给你一辆整车而LLVM给你的是发动机、变速箱、底盘这些零件你可以自己拼装成适合不同场景的车。这套零件库的核心就是LLVM IRIntermediate Representation也就是中间表示。它不绑定任何具体编程语言也不绑定具体的CPU架构。比如C代码可以编译成LLVM IRRust可以编译成LLVM IRSwift也能Unity的Burst则是把C#/IL编译成LLVM IR。然后LLVM负责对这个IR做优化最后再根据目标平台生成对应的机器码。这个“语言无关的中间层”才是LLVM最狠的地方。因为编译器里最难、最值钱的部分从来不是“解析语法”或者“生成汇编”而是中间层优化。同一套优化Pass你给C用给C#用给Rust用效果都一样好。苹果当年相中LLVM把整个开发工具链从GCC迁过去看中的就是这种可控的模块化能力。1.2 LLVM的三大阶段前端、优化器、后端一次完整的LLVM编译流程可以分成三段前端把源代码变成LLVM IR。Clang就是C/C/Objective-C的前端。优化器对LLVM IR做一连串分析优化。这里跑的就是各种Pass比如循环展开、内联、公共子表达式消除、自动向量化。后端把优化后的IR变成目标机器的汇编或者机器码。比如x86_64、ARM64、RISC-V的后端。这套架构最大的优势是“一次优化到处生成”。后端只要支持某种CPU架构所有能产生LLVM IR的语言就都能直接利用这层支持。Burst就是直接吃到了这波红利Unity不需要自己为每一代移动CPU写指令调度只需要把C#转成合格的LLVM IR后续的事全交给LLVM。我在实际接触LLVM之前总觉得“编译器优化”是个很玄的事情。看多了LLVM的Pass实现之后才意识到优化其实是一套有严格数学基础的重写规则比如循环不变量外提、死代码删除都是有明确的适用条件和安全边界的。Burst之所以能把C#的Job代码优化到接近手写C的水平就是因为这些重写规则被真正执行了一遍。2. Burst CompilerUnity里那道“把托管代码变成原生代码”的工序2.1 为什么Unity需要Burst先理解C#的托管世界Unity的常规C#代码跑在Mono或IL2CPP之上。Mono是解释执行加JITIL2CPP是把C#的IL转换成C再编译成原生代码。这两种方案的通用性很好但都存在一定的性能损失。拿Mono来讲JIT编译虽然启动快但对热路径代码的优化时间非常有限。而且托管运行时里还有GC垃圾回收这层干扰对象分配频繁会导致GC卡顿。IL2CPP虽然生成了原生代码但它本质上还是模拟了一套.NET运行时存留了许多托管语义比如异常处理、类型转换检查、虚方法分发这些都会让生成的机器码“不够纯粹”。而Burst的目标非常明确只处理你明确标记为高性能的代码——通常是IJob、IJobParallelFor这些Job结构体里的方法配合Unity.Mathematics库然后用LLVM做激进优化最后生成高度优化的本地代码。它不管你整个游戏的所有C#代码只管那些“计算密集型”的切片。2.2 Burst的工作流程从C#到机器码的完整链条我用一张流程拆解来展示Burst从源码到二进制发生了什么C#源码编译成IL程序集。Burst的编译器前端把IL解析成Burst内部表示的IR。对IL做必要的合法性检查去掉不符合Burst安全模型的特性。将IR转换成LLVM IR。LLVM优化器跑一系列优化Pass。LLVM后端针对当前目标平台生成优化后的机器码。在编辑器里通过Burst Inspector可以查看每个阶段产出的中间表示以及最终汇编。这个链条里步骤3非常关键。Burst本质上是牺牲了通用性来换性能所以它强制要求代码不能有托管对象引用、不能用大部分BCL库、不能随意捕获变量、虚调用也基本不允许。你写的Job代码必须是“纯数据 纯函数式”的C#子集。2.3 Burst和Direct Call不只是Job专属很多人以为Burst只能配合Job System用其实Burst还提供了[BurstCompile]函数的Direct Call方式你可以把一个静态方法标记为[BurstCompile]然后在普通Mono/IL2CPP代码里直接调用它。Unity会生成一个高性能的原生函数入口调用时直接执行机器码而不必走托管委托。比如写一个计算点积的函数[BurstCompile] public static float CustomDot(float3 a, float3 b) { return a.x * b.x a.y * b.y a.z * b.z; }然后在Update里直接调用。Unity会自动把它编译成原生代码并在调用时通过函数指针直接执行。配合[BurstDiscard]、[AssumeRange]等特性可以把许多临界代码的性能再压榨一截。Direct Call模式特别适合那些高度敏感的算法函数比如空间哈希、密度场采样、PID控制器更新。我在做程序化生成的时候把部分采样函数用Direct Call后一帧节省的时间肉眼可见。3. 深入优化细节让Burst真正发力的C#编码范式3.1 不要再用“普通的C#思维”写高性能代码很多开发者刚开始用Burst还是会写这样的代码[BurstCompile] public struct MyJob : IJobParallelFor { public NativeArrayfloat inputs; public NativeArrayfloat outputs; public void Execute(int i) { var x inputs[i]; if (x 1f) { outputs[i] x * 2f; } else { outputs[i] x * 3f; } } }这段代码Burst能编译也能优化得不错。但真正让它跑得更快你需要引入几类关键优化把循环不变量提到循环外使用math函数而不是Mathf因为Unity.Mathematics里的函数被标记了[BurstCompile]友好的内联属性更贴近SIMD尽量使用NativeArrayT连续内存避免指针跳跃尽量让每个线程处理的数据彼此独立没有写冲突。Burst自带很强的自动向量化能力但前提是你的代码里没有“让编译器没法确认内存别名”的操作。比如你同时读写两个NativeArray编译器不知道它们是不是指向同一块内存就只能保守地按标量处理。用[NativeDisableContainerSafetyRestriction]这种特性之前你需要自己确保安全性但它确实能帮助编译器做更多激进的优化。3.2 用Burst Inspector看优化前后差异LTO和Fast MathBurst提供了Burst Inspector工具在Jobs Burst Inspector里打开。它能展示C#IL、Burst IR、LLVM IR和最终汇编。我之前排查一个Job性能问题就是用这个工具发现我写的一个pow(a, b)函数并没有被优化成vpow指令而是走了一个通用库调用。后来改成math.pow并且打开Fast Math选项汇编瞬间变成了几条SIMD指令。Fast Math是Burst里一个必须慎重使用的开关。它会让编译器假设浮点运算中不存在NaN、Infinity、严格舍入等需求从而用更激进的数学等价变换。对游戏来说大部分数值计算不需要严格IEEE语义所以开启后性能往往有明显提升。但是如果你做的是物理精确计算或者需要稳定复现的随机结果最好保持关闭或者至少测试后评估差异。3.3 避免破坏Burst优化的几个“经典原罪”我自己踩过不少让Burst编译出来的代码反而变慢的坑。这里列几个最经典的在Job内使用Debug.Log。虽然Burst支持[BurstDiscard]特性去掉这种调用但只要有这种调用在编译器无法内联和优化相关代码。至少会污染周围的指令调度。捕获外部局部变量里的class对象。Burst能捕获的只是struct或NativeContainer。如果你不小心捕获了一个Listfloat它会编译失败于是整个Job退化回Mono执行性能自然就没了。过度使用SharedStaticT但没理解生命周期。它快但管理不善会造成静态状态污染。整数除法依赖变量。如果除数是运行时才知道的数编译器很难优化成乘法替代性能差距极大。建议预计算倒数或者保证除数是常量。这是我在一个粒子碰撞系统里遇到的真实例子我原本写了一个随机数生成器用了float和uint的按位异或、乘加Burst和LLVM优化后每个粒子只需要几个周期。后来我为了“方便”改用了UnityEngine.Random.Range整个Job直接炸回托管代码效率一落千丈。这类问题你从编译结果里会看到Bursted状态直接变成了Not Bursted原因也写得很清楚。4. Burst在实际项目里的性能收益与排查工具链4.1 性能对比我拿数据说话我实际测试过一个包含50万粒子的位置更新系统。使用普通C# Job运行在Mono上的耗时大约是每帧18毫秒改成Burst后开启Fast Math、编译器使用AVX2耗时掉到了约0.6毫秒——接近30倍提升。这种量级的收益不是靠什么算法魔法而是LLVM把每颗粒子的计算内联成了一个紧凑的SIMD流水线。需要说明的是实际收益高度依赖代码类型。如果本身就是内存带宽瓶颈的Job比如大量复制连续内存Burst能带来的提升会小一些因为瓶颈在内存带宽不在CPU指令。这能解释为什么有人用了Burst却觉得“也就那样”——你要先搞清楚自己的瓶颈到底在哪。用Unity Profiler的CPU视图看一下线程耗时能快速判断是不是受限于计算。4.2 我的调试工具栈从Profiler到Native Debugger使用Burst时我一般会搭配这几类工具Unity Profiler主要确认Job线程的耗时比例看是否真的有大量时间花在某个Job上。Burst Inspector看编译状态和生成的指令确认Job确实被Burst接管。Native Debugger如Visual Studio或macOS上的LLDB当你想下条件断点调试原生代码时需要把Burst的“Native Debugging”选项启用然后就能在C#里打断点直接调试生成的本地代码。Unity的Jobs窗口Profiler模块可以查看哪些Job被调度到了哪个线程。我强烈建议每次改完性能关键代码都要在Burst Inspector里看一眼“Compilation Status”。如果显示为Method failed to compile后面通常有原因。大多数情况是编译器检测到不支持的类型或者不安全代码。排查思路是先把代码简化到最小复现再逐步添加功能直到找出导致编译失败的因子。4.3 几类常见的Burst编译失败与解决方案下面这张表是我整理的高频问题现象常见原因解决方案编译报“GC Managed memory access”函数内访问了class或string改成结构体、NativeText或FixedString编译报“The method is not supported”使用了未标注[BurstCompile]的外部函数把逻辑迁入Job或使用支持函数编译状态为“Not Bursted”捕获了未支持的容器/对象或者使用了System.Linq去掉所有LINQ和class捕获性能不如预期存在函数调用未内联用[MethodImpl(MethodImplOptions.AggressiveInlining)]主动提示内联随机结果不一致开启了Fast Math但依赖浮点严格语义关闭Fast Math或修改随机算法这些问题的修复思路核心都要回到“Burst是给可静态分析的确定性代码准备的”这条原则上。你写代码时脑子里要有一根弦这段代码在编译时有没有办法被完全确定类型、调用路径、内存边界。越符合静态分析的口味LLVM就能优化得越激进。5. 进一步挖掘底层能力SIMD、指针和Unity.Mathematics5.1 写显式SIMD代码从浮点标量到向量Burst能自动把循环向量化但有时自动向量化效果不够好尤其当循环内部存在复杂分支或非优美步长时。这时可以手写显式SIMD利用Unity.Mathematics里已经定义好的float3、float4、int4等类型直接做逐元素运算。比如计算4个浮点数的平方根var x4 new float4(1f, 2f, 3f, 4f); var result math.sqrt(x4); // 编译器映射为SIMD sqrt指令这段代码在x86平台生成后通常是vsqrtps指令一次性处理4个float。如果写循环编译器自动向量化也未必能保证。显式SIMD本质上是“替编译器做决策”适合那些编译器无法确定安全变换的场景。5.2 在Burst中使用指针与SpanBurst里你可以使用fixed和指针操作这会绕过托管安全机制换来更高的自由度。但前提是你需要确保指针生命周期有效。常见的用法是把NativeArray的GetUnsafeReadOnlyPtr()指针传入一个内联函数[BurstCompile] public unsafe struct MyJob : IJobParallelFor { [NativeDisableUnsafePtrRestriction] public float* inputPtr; public float* outputPtr; public void Execute(int i) { outputPtr[i] inputPtr[i] * 2f; } }这里我用了unsafe和[NativeDisableUnsafePtrRestriction]。真正发布时你需要在Project Settings里启用Allow unsafe code。指针带来的收益主要是去掉了一些容器访问的边界检查和指针解引用开销以及对内存布局更精细的控制。不过我不建议新手一上来就到处用指针。Burst的优化器已经非常聪明很多时候天然生成的代码不见得比手写指针慢。指针的唯一优势在于告诉编译器“我用的是连续地址”而NativeArray也能做到这一点。所以优先用NativeArray只有当Profiler明确告诉你访问开销还是瓶颈时再考虑指针。5.3 用好[AssumeRange]和[Unity.Collections.LowLevel.Unsafe.NativeDisableContainerSafetyRestriction][AssumeRange]是Unity.Mathematics提供的一个很实用的特性可以告诉Burst某个整数类型的范围。比如[return: AssumeRange(0, 100)] private static int GetIndex(int i) { return i % 101; }这能帮助LLVM做更精确的边界消除去掉数组越界检查也为循环展开提供信息。实际项目中我常在Job索引从NativeArray读取后顺手加上范围假设优化效果明显。另一个特别容易踩的坑是NativeArray的读和写都带有“安全检查”开启Jobs Burst后这些检查通常会被移除但如果你在编辑器中把安全模式打开会看到输出中多了些判断分支。发布版本里这些分支会消失。如果你不希望它们出现在Profiler数据里可以了解一下Compilation Safety Checks选项。6. 常见坑与进阶排查从编译失败到诡异性能回退6.1 编译成功但性能变差Burst版本与平台差异我曾遇到一个情况同一个Job在同一台机器上Unity 2021版的Burst编译后耗时2毫秒升级到2022版后变成4毫秒。排查了很久才发现是Burst默认Target CPU的基线变了。新版Unity默认的Optimize For选项可能从“当前平台”变成了“兼容模式”导致SSE指令集选择保守之后我手动将Target CPU设置为AVX2才恢复性能。这说明Burst的优化选项并不是“默认最好”。你需要在Project Settings Burst AOT Settings里仔细配置Target Architecture选择当前主力机型对应的CPU架构不要一味选保守的baseline。Enable Compilation确认真机/模拟器上都启用了。Force Debug Information发布时关闭否则会保留大量调试符号。6.2 从“编译器内部错误”中恢复Burst偶尔会冒出来一些让人摸不着头脑的版本配合错误例如Burst failed to compile method: Internal compiler error in llvm.这个我见过十几次。最常见的诱因是Unity编辑器版本与Burst包版本不匹配或者某些语法在IL层面生成了Burst不期望的节点。解决办法一般是这样升级或降级com.unity.burst包版本尽量和Unity版本匹配。尝试“清除Burst缓存”Jobs Burst Regenerate Burst Inspection。把问题函数缩小到最小复现单元然后在Unity的Issue Tracker上搜相似问题。如果是编译器崩溃可以考虑把这段代码从Burst中移出用[BurstDiscard]或单独的非Burst函数兜底同时报告给Unity。6.3 数据竞争与确定性Burst代码不是“脱缰野马”Burst只是编译器它不会自动保证线程安全。多个Job同时写入同一个NativeArray的不同索引时看起来没问题但如果你写的是相邻索引某一些平台上缓存行伪共享会造成性能骤降。解决办法是让每个线程处理连续的大块数据或者将数据填充到缓存行对齐。另外Burst的一个隐性优势在于“确定性”。同一份Burst代码同样输入几乎总能产生相同输出如果你关闭Fast Math并使用相同SIMD指令集。这在做网络同步、回放系统时非常重要。我在做帧同步玩法时专门把所有物理结算都放进Burst Job里避免Mono JIT在不同浮点架构上产生不同结果。7. 一些实战心得与后续扩展方向最后说点实际感受。Burst最大的价值并不是“某个Job快了30倍”而是它让你在Unity里写出接近C性能的代码却不用离开C#的舒适区。它也不是银弹你仍然需要思考算法、数据结构、缓存利用率。但当你已经完成了算法层面的优化后Burst就是那个帮你压榨最后几倍收益的强有力工具。如果你刚开始接触这套体系我建议先做一件小事找项目里最热的一个数据密集型Job用Burst编译并打开Burst Inspector对照优化前后的汇编感受一下什么叫做“编译器视角”。这一步比任何理论讲解都有用。以后的技术路线我还会继续深入这些方向在自定义的NativeContainer里加入Burst友好的API利用Burst的DispatchToMainThread特性处理提交渲染相关数据构建基于Burst的运行时网格动画系统把Burst跟RenderGraph结合做一个纯Burst驱动的批次提交。这条路会很长但只要掌握了LLVM和Burst的协作原理后续每一个方向都会顺畅很多。最后再分享一个调试小技巧当你怀疑某个Job没有被Burst编译时用Burst Inspector搜方法名如果没有出现基本就是捕获了非Burst兼容的东西。这时候不用瞎猜把捕获字段一个一个删掉重新编译很快就能定位到问题。这点在做性能优化排查时能帮你省掉大量时间。