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
JobHandle handle = job.Schedule();

Schedule只是把任务提交给Job System,并返回一个JobHandle。Job可能马上执行,也可能过一会才被WorkerThread取走,不能依赖具体执行时机。

如果这样写:

1
2
JobHandle handle = job.Schedule();
handle.Complete();

MainThread刚提交就立刻等待,Job即使去了其他线程,主线程也没有做其他工作。这样只是把同步代码换了一个线程执行,通常得不到理想收益,甚至会因为调度开销更慢。

更好的思路是:

帧开始尽早Schedule。

MainThread继续执行不依赖Job结果的工作。

真正使用结果之前再Complete。

也就是让MainThread工作和Job执行尽可能重叠。

3.JobHandle和依赖关系

Job之间经常有依赖关系,例如A计算位置,B读取位置做剔除。

1
2
JobHandle moveHandle = moveJob.Schedule(count, 64);
JobHandle cullHandle = cullJob.Schedule(count, 64, moveHandle);

这里CullJob不会在MoveJob完成前执行。Job System根据JobHandle构建依赖关系,保证读写顺序正确。

多个独立Job都完成后才能继续,可以合并依赖:

1
2
JobHandle dependency = JobHandle.CombineDependencies(handleA, handleB);
JobHandle finalHandle = finalJob.Schedule(dependency);

Job依赖应该形成尽量宽的DAG,而不是所有任务排成一条很长的链。

宽依赖:A、B、C可以同时执行,最后D等待三个结果。

长依赖:A完成后B,B完成后C,CPU核心大部分时间可能空闲。

Complete做了什么?

Complete不仅是查看Job是否结束,还会让调用线程等待依赖链完成,并把NativeContainer的所有权安全地交还给MainThread。

所以不要只用IsCompleted判断后就直接访问数据。真正访问结果前仍然需要Complete

4.常见Job类型

IJob

Execute只执行一次,适合一个相对完整的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[BurstCompile]
public struct SumJob : IJob
{
[ReadOnly] public NativeArray<float> Values;
public NativeArray<float> Result;

public void Execute()
{
float sum = 0;
for (int i = 0; i < Values.Length; i++)
sum += Values[i];

Result[0] = sum;
}
}

IJob会进入WorkerThread,但单个Job本身不会自动拆到多个核心。

IJobParallelFor

适合对大量独立元素执行相同逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
[BurstCompile]
public struct MoveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Velocity;
public NativeArray<float3> Position;
public float DeltaTime;

public void Execute(int index)
{
Position[index] += Velocity[index] * DeltaTime;
}
}

调度:

1
JobHandle handle = moveJob.Schedule(Position.Length, 64);

Job System会把所有Index拆成多个Batch分给WorkerThread。当某个线程先做完时,可以通过Work Stealing拿走其他线程的部分Batch。

IJobFor

IJobFor同样按Index处理数据,可以根据调度方式串行或并行执行。新代码可以根据使用的Unity和Collections版本选择IJobForIJobParallelFor或对应的Entities Job。

重点不在接口名字,而在于每个Index是否可以独立计算,以及数据访问是否连续。

5.Batch Size怎么设置?

1
job.Schedule(length, innerloopBatchCount);

第二个参数控制一个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
2
[ReadOnly] public NativeArray<float3> Input;
public NativeArray<float3> Output;

如果不加[ReadOnly],Job System默认认为Container可能被写入。多个只读Job本来可以并行,却可能因为写权限产生不必要的依赖或直接无法调度。

明确读写权限既是安全信息,也是并行调度和Burst优化的重要信息。

Allocator选择

Allocator.Temp:生命周期很短,通常只用于当前帧的临时Native内存,不能作为普通Job字段跨线程使用。

Allocator.TempJob:用于短生命周期Job数据,需要在规定帧数内释放。

Allocator.Persistent:可以长期存在,分配释放最贵,适合重复使用的数据。

不要每帧创建和销毁大NativeArray。长度稳定的数据使用Persistent长期复用,通常比每帧分配更好。

1
JobHandle disposeHandle = array.Dispose(dependency);

可以让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
2
3
4
5
[BurstCompile]
public struct MoveJob : IJobParallelFor
{
//...
}

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
2
3
4
for (int i = 0; i < length; i++)
{
output[i] = inputA[i] + inputB[i];
}

标量代码每次处理一个元素。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
2
[ReadOnly] public NativeArray<float> Input;
public NativeArray<float> Output;

使用Unsafe指针、切片或复杂函数传参后,别名关系可能更难分析。不要为了“少一个NativeArray”让多个逻辑共享一块难以判断的内存,否则可能失去编译器优化。

11.FloatMode和精度

1
[BurstCompile(FloatPrecision.Medium, FloatMode.Fast)]

FloatMode.Fast允许Burst重排浮点运算,并使用更快或精度更低的硬件指令,例如把乘法和加法合并。

适合:

粒子、视觉效果、普通移动等允许少量误差的计算。

谨慎使用:

确定性模拟、网络帧同步、精确几何判断、依赖NaN/Inf行为的代码。

现在的Burst还提供Deterministic模式用于跨平台一致性,但它会关闭部分浮点优化,而且确定性仍然要求输入、非Burst代码和硬件特殊指令也满足条件。

不要全项目无脑Fast。先确定误差可接受,再比较性能。

12.一个完整的Job执行例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class UnitMoveSystem : MonoBehaviour
{
NativeArray<float3> positions;
NativeArray<float3> velocities;
JobHandle moveHandle;

void OnEnable()
{
positions = new NativeArray<float3>(10000, Allocator.Persistent);
velocities = new NativeArray<float3>(10000, Allocator.Persistent);
}

void Update()
{
MoveJob job = new MoveJob
{
Position = positions,
Velocity = velocities,
DeltaTime = Time.deltaTime
};

// 尽早提交
moveHandle = job.Schedule(positions.Length, 64, moveHandle);

// MainThread继续执行不依赖positions的逻辑
}

void LateUpdate()
{
// 真正需要结果时再同步
moveHandle.Complete();

// 把结果同步给Transform或上传到GPU
}

void OnDisable()
{
moveHandle.Complete();
positions.Dispose();
velocities.Dispose();
}
}

这个例子最重要的不是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 1.8 Manual

Unity Burst - Marking code for Burst compilation

Unity Burst - Loop vectorization

Unity Burst - Memory aliasing