Unity-Jobs与Burst原理和优化
记录一下Unity Jobs和Burst的原理以及常见优化。
Jobs和Burst经常一起出现,但是两者解决的问题不同:
Jobs:把工作拆开,调度到多个CPU核心上并行执行。
Burst:把一部分C#编译成高性能Native机器码,并进行SIMD等优化。
所以Jobs不一定快,Burst也不等于多线程。一般是把数据整理成适合并行的Job,再交给Burst编译,两个一起使用效果最好。
1.为什么需要Jobs?
Unity大部分游戏逻辑默认运行在MainThread。
Update
动画更新
物理结果同步
渲染提交
GameObject和Component操作
这些工作都挤在主线程上,只要一帧超过16.6ms,60FPS就保不住了。
普通MonoBehaviour代码即使机器有很多CPU核心,也不会自动并行。Jobs的作用就是把适合的计算任务放到Unity管理的WorkerThread上,让主线程和多个CPU核心同时工作。
适合Job的任务:
大量单位的移动、距离计算和状态更新。
顶点、粒子、草地、布料等批量数据处理。
寻路、空间划分、剔除、碰撞预计算。
不依赖GameObject API的纯数学计算。
不太适合Job的任务:
只有几十次的简单计算。
每个任务都要访问Transform、Animator、Physics等主线程API。
数据之间有很强的前后依赖,无法并行。
Job很小,但每帧创建、调度、同步很多次。
Jobs有调度和同步成本,不是把所有函数套一个Job就会变快。
2.Job System的执行原理
Unity启动时会根据设备CPU核心情况创建WorkerThread。开发者不需要自己创建和销毁线程,只需要向Job System提交工作。
大致流程是:
1.MainThread创建Job数据。
2.调用Schedule,把Job放入调度队列。
3.WorkerThread从队列获取Job并执行Execute。
4.空闲WorkerThread可以从其他线程队列偷取工作,也就是Work Stealing。
5.MainThread需要结果时调用JobHandle.Complete进行同步。
Schedule不是立即执行
1 | |
Schedule只是把任务提交给Job System,并返回一个JobHandle。Job可能马上执行,也可能过一会才被WorkerThread取走,不能依赖具体执行时机。
如果这样写:
1 | |
MainThread刚提交就立刻等待,Job即使去了其他线程,主线程也没有做其他工作。这样只是把同步代码换了一个线程执行,通常得不到理想收益,甚至会因为调度开销更慢。
更好的思路是:
帧开始尽早Schedule。
MainThread继续执行不依赖Job结果的工作。
真正使用结果之前再Complete。
也就是让MainThread工作和Job执行尽可能重叠。
3.JobHandle和依赖关系
Job之间经常有依赖关系,例如A计算位置,B读取位置做剔除。
1 | |
这里CullJob不会在MoveJob完成前执行。Job System根据JobHandle构建依赖关系,保证读写顺序正确。
多个独立Job都完成后才能继续,可以合并依赖:
1 | |
Job依赖应该形成尽量宽的DAG,而不是所有任务排成一条很长的链。
宽依赖:A、B、C可以同时执行,最后D等待三个结果。
长依赖:A完成后B,B完成后C,CPU核心大部分时间可能空闲。
Complete做了什么?
Complete不仅是查看Job是否结束,还会让调用线程等待依赖链完成,并把NativeContainer的所有权安全地交还给MainThread。
所以不要只用IsCompleted判断后就直接访问数据。真正访问结果前仍然需要Complete。
4.常见Job类型
IJob
Execute只执行一次,适合一个相对完整的任务。
1 | |
IJob会进入WorkerThread,但单个Job本身不会自动拆到多个核心。
IJobParallelFor
适合对大量独立元素执行相同逻辑。
1 | |
调度:
1 | |
Job System会把所有Index拆成多个Batch分给WorkerThread。当某个线程先做完时,可以通过Work Stealing拿走其他线程的部分Batch。
IJobFor
IJobFor同样按Index处理数据,可以根据调度方式串行或并行执行。新代码可以根据使用的Unity和Collections版本选择IJobFor、IJobParallelFor或对应的Entities Job。
重点不在接口名字,而在于每个Index是否可以独立计算,以及数据访问是否连续。
5.Batch Size怎么设置?
1 | |
第二个参数控制一个Batch连续处理多少个元素。
Batch很小:
任务分配更均匀,某些元素特别慢时不容易让一个线程拖尾。
但是Batch数量多,调度、Work Stealing和原子操作开销更大。
Batch很大:
调度次数少,连续处理数据的Cache Locality更好。
但是任务可能分配不均,部分WorkerThread提前空闲。
常见思路:
每个元素工作很重且耗时不均匀——>Batch小一些,例如1、8、16。
每个元素只有几条简单计算——>Batch大一些,例如64、128甚至更大。
没有一个值适合所有平台。PC和移动端CPU核心数、大小核结构、Cache都不同,需要用Profiler在目标设备上测试。
6.NativeContainer为什么需要?
普通托管对象由GC管理,内存可能移动,而且多个线程同时读写引用对象容易产生Race Condition。Burst也不能随意编译包含托管对象的代码。
Jobs通常使用NativeContainer:
NativeArray
NativeList
NativeHashMap
NativeQueue
NativeStream
NativeContainer本质上是Native内存的C#包装,并带有线程安全检查信息。Job Struct本身在Schedule时会复制,但NativeArray复制的是指向Native内存的句柄,不会把数组全部复制一遍。
ReadOnly很重要
1 | |
如果不加[ReadOnly],Job System默认认为Container可能被写入。多个只读Job本来可以并行,却可能因为写权限产生不必要的依赖或直接无法调度。
明确读写权限既是安全信息,也是并行调度和Burst优化的重要信息。
Allocator选择
Allocator.Temp:生命周期很短,通常只用于当前帧的临时Native内存,不能作为普通Job字段跨线程使用。
Allocator.TempJob:用于短生命周期Job数据,需要在规定帧数内释放。
Allocator.Persistent:可以长期存在,分配释放最贵,适合重复使用的数据。
不要每帧创建和销毁大NativeArray。长度稳定的数据使用Persistent长期复用,通常比每帧分配更好。
1 | |
可以让Container在依赖Job执行结束后释放,避免为了Dispose提前Complete。
Safety System
Unity会跟踪NativeContainer的读写状态:
多个Job可以并行读取同一个Container。
有Job写入时,其他读写必须通过依赖保证顺序。
Job未完成时,MainThread不能直接访问正在使用的数据。
[NativeDisableParallelForRestriction]、Unsafe指针等可以绕过限制,但只是关闭安全保护,不会自动解决Race Condition。除非已经证明每个线程写入互不重叠,否则不要把关闭检查当优化手段。
7.Burst的编译原理
普通C#大致执行流程:
C#——>IL——>Mono JIT或IL2CPP——>机器码
Burst流程可以理解为:
HPC#代码——>.NET IL——>Burst前端——>LLVM优化——>目标CPU Native机器码
Burst读取标记为[BurstCompile]的Job或静态函数,将支持的C#子集编译成针对目标CPU架构优化的代码。
1 | |
Burst主要带来的收益:
1.减少普通托管代码的边界检查和运行时开销。
2.函数内联、常量折叠、死代码删除等编译器优化。
3.自动Loop Vectorization,使用SSE、AVX、NEON等SIMD指令。
4.根据内存别名信息进行更激进的加载、存储和循环优化。
5.AOT构建时针对目标平台生成Native代码。
Burst不会自动让一个Job变成多线程。IJob加Burst仍然只执行一次,只是单线程代码更快;并行来自Job System的调度方式。
8.Burst为什么适合数据导向代码?
CPU访问内存时,连续数据比到处跳转的对象引用更容易利用Cache和SIMD。
传统GameObject思路:
List<Enemy>——>每个Enemy引用Transform、配置、状态对象——>内存位置分散
数据导向思路:
NativeArray<float3> Positions
NativeArray<float3> Velocities
NativeArray<float> Healths
相同类型数据连续保存,Job按Index顺序访问,更容易预取到Cache Line,也更容易让Burst一次处理多个元素。
但SoA不是任何时候都比AoS快。如果每次逻辑都需要同一个对象的所有字段,一个紧凑Struct数组也可能很好。重点是只读取当前算法需要的数据,并保持访问连续。
9.SIMD和Loop Vectorization
例如普通循环:
1 | |
标量代码每次处理一个元素。Burst确认数据不重叠并且循环适合向量化后,可以生成一次处理多个Float或Int的SIMD指令。
有利于向量化的代码:
循环次数明确。
连续读写NativeArray。
每次迭代互相独立。
使用Unity.Mathematics的float2、float3、float4和math函数。
循环体简单,没有复杂控制流。
容易阻止向量化的情况:
循环中间break或return。
每次迭代依赖上一次结果。
大量不可内联函数、异常和复杂分支。
随机访问内存。
编译器无法确定Input与Output是否指向重叠内存。
注意:float4不等于一定产生SIMD,普通Float循环也可能被Burst自动向量化。最终要看Burst Inspector的汇编结果。
10.内存别名Alias
如果编译器不知道Input和Output是否指向重叠内存,就不能随意调整读写顺序,否则可能改变结果。
Job里的不同NativeContainer通常能给Burst更明确的NoAlias信息,从而帮助循环向量化。
1 | |
使用Unsafe指针、切片或复杂函数传参后,别名关系可能更难分析。不要为了“少一个NativeArray”让多个逻辑共享一块难以判断的内存,否则可能失去编译器优化。
11.FloatMode和精度
1 | |
FloatMode.Fast允许Burst重排浮点运算,并使用更快或精度更低的硬件指令,例如把乘法和加法合并。
适合:
粒子、视觉效果、普通移动等允许少量误差的计算。
谨慎使用:
确定性模拟、网络帧同步、精确几何判断、依赖NaN/Inf行为的代码。
现在的Burst还提供Deterministic模式用于跨平台一致性,但它会关闭部分浮点优化,而且确定性仍然要求输入、非Burst代码和硬件特殊指令也满足条件。
不要全项目无脑Fast。先确定误差可接受,再比较性能。
12.一个完整的Job执行例子
1 | |
这个例子最重要的不是Move公式,而是生命周期:
NativeArray长期复用。
Schedule尽量早。
Complete尽量晚。
下一帧调度时传入前一帧Handle,保证数据没有同时写入。
Dispose前一定完成所有使用它的Job。
实际项目也可以做一帧延迟,让第N帧消费第N-1帧的Job结果,获得更大的并行窗口。
13.常见性能问题
1.Job粒度太小
每个单位创建一个IJob,比直接循环还慢。应该把大量同类数据放入一个ParallelFor Job。
2.Schedule之后马上Complete
MainThread没有和WorkerThread重叠,Profiler里会看到明显等待。
3.Job依赖链太长
所有Job串行执行,多核没有充分利用。应该把无依赖的任务并行调度,最后CombineDependencies。
4.每帧分配NativeContainer
产生分配释放开销,也容易忘记Dispose。固定规模数据尽量Persistent复用,需要变长时使用NativeList或容量管理。
5.Batch Size不合适
Batch太小调度成本高,太大负载不均。不要照抄固定数字。
6.随机内存访问
Job虽然跑满所有核心,但都在等待Cache Miss,CPU利用率高不代表有效工作多。
7.只加BurstCompile就认为结束了
Burst可能因为不支持的托管代码无法编译,也可能成功编译但没有向量化。要检查Burst Inspector和编译警告。
8.关闭安全检查掩盖数据竞争
Editor里的Safety Check确实有成本,但它能发现越界、并发写入和生命周期问题。Release性能要在Player真机测,不要通过关闭安全保护修复错误设计。
9.在Job里调用UnityEngine对象
大部分UnityEngine API只能在MainThread调用。常见做法是:
MainThread收集GameObject数据——>Job批量计算——>MainThread应用结果
如果来回复制和应用结果的成本大于计算本身,就不适合Jobs,或者需要进一步改成数据导向架构。
14.怎么分析Jobs和Burst性能?
Unity Profiler
主要查看:
MainThread是否在等待Job。
WorkerThread是否有大量空闲。
Job之间有没有形成长依赖链。
Schedule和Complete之间是否有足够重叠。
真正耗时是计算、内存访问还是数据同步。
Profiler里MainThread出现等待Job的Marker,不一定说明Job本身慢,也可能是Complete调用太早。
Burst Inspector
可以查看:
Burst是否成功编译。
优化后的LLVM IR和目标平台汇编。
循环是否生成SIMD指令。
是否因为分支、Alias或函数调用退化为标量代码。
对Burst优化来说,Inspector里的机器码通常比C#源码看起来“像不像优化”更可靠。
Profile Analyzer和真机测试
Jobs调度受CPU核心数、大小核、系统负载和温度影响。Editor结果只能帮助定位,最终需要在Player和目标设备上比较:
普通C#单线程。
Burst单线程。
Jobs不开Burst。
Jobs + Burst。
这样才能判断收益来自多线程还是编译优化,也能发现任务规模太小时Jobs反而更慢。
15.Jobs和Burst优化顺序
个人觉得比较稳妥的顺序是:
1.先用Profiler确认MainThread确实是CPU瓶颈。
2.找到数据量大、逻辑相同、互相独立的循环。
3.把托管对象整理成连续NativeContainer数据。
4.先实现正确的Job依赖和生命周期。
5.加BurstCompile,处理不支持的托管代码。
6.Schedule尽量早,Complete尽量晚。
7.调整Batch Size和Job粒度。
8.用Burst Inspector确认SIMD、Alias和FloatMode。
9.在目标设备上测试耗时、功耗和温度。
16.核心总结
Jobs优化的是多核利用率,Burst优化的是单个CPU核心执行代码的效率,NativeContainer和数据导向设计是两者能够高效工作的基础。
一句话理解:
Jobs负责把活分给多个工人。
Burst负责给每个工人更好的工具和做法。
NativeContainer负责把材料整齐、安全地放在工人能访问的位置。
真正影响性能的核心是:
Job是否足够大,能够覆盖调度成本。
Job之间是否有足够并行空间。
MainThread有没有过早Complete。
数据是否连续,是否对Cache和SIMD友好。
Burst是否真的生成了更好的机器码。
所以Jobs + Burst不是一个开关,而是调度、依赖、内存布局和编译优化一起工作的结果。
引用参考:
Unity Manual - Job system overview
Unity Manual - Job dependencies
Unity Manual - ParallelFor jobs
Unity Manual - Introduction to NativeContainer
Unity Burst - Marking code for Burst compilation