前言
本文是上一篇《Unity ECS 高性能探索》的续篇。在上一篇,我们用ECS实现了PC下同屏2w个球体,编辑器下跑到了120FPS。而在本篇,我们把性能继续解放,加上Job System,把同屏推进到8w个球体,60FPS。
性能瓶颈我们先来看一下上一篇实现的ECS同屏2w个球体,如果加到8w个会怎么样
只有不到40FPS,上一篇里我们提到了,开启GPU Instancing以后,性能热点从提交Draw Call转移到了物理运算。现在从2w个球体提高到8w个,性能热点又转移到哪了呢?如果我们想要更多怎么办?
首先我们分析下,37FPS * 每帧for循环处理8w个物体,也就是即使每次for循环里只做一次加法,每秒也接近300w次加法。更何况在Demo里,for循环里做的还是物理运算还有随机数运算。我们先来看看当前环境下的极限运算能力:
void Start()
{
float t1 = Time.realtimeSinceStartup;
float tmp = 0f;
int cnt = 10000000;
for (int i = 0; i < cnt; ++i)
{
tmp += Random.Range(-10f, 10f);
}
Debug.Log("Calculation per second:" + (int)(cnt / (Time.realtimeSinceStartup - t1)));
}
大约是每秒做3700w次加法和随机数
而上一篇的Demo里,最坏情况下,for循环里要执行2次加法,2次乘法,1次随机数。再加上数组索引、变量赋值、getter/setter的调用和new的开销,而且还有渲染和维护8w个对象的基本引擎开销。我们可以大致推断出:37FPS已经逼近当前环境下单核的运算极限了。
当然,我们可以通过一些代码优化的手段减少for循环里的运算以提升一点效率,但很明显提升不会太大。在单核已经接近极限的情况下,考虑的解决方案就只能是多线程,把运算转移到别的核里去。
在没有Job System以前,由于受限于Unity引擎的对象只能在主线程里访问,我们一般的做法是:抽象出自定义的数据结构,放到子线程里运算,主线程每帧取子线程的运算结果,应用到相应的对象上。
Job System解决的是什么问题如上所述,我们如果遇到卡主线程的逻辑,一般考虑丢给子线程解决。但是在游戏开发中,我们常常遇到的一般是“碎片化”的任务逻辑,这些任务相互独立,来得快消失得也快。比如屏幕上的子弹,子弹的物理运动可以单独运算,如果每颗子弹都开启一个线程,这样线程开启和销毁的开销也太大。那也许你说搞个线程池重用吧,线程数太多的话上下文切换的开销也是很可观的,更何况怎么知道开多少个线程合适呢?
我们知道最理想的状态下,每个核绑定一个线程是最好的,但由于平台和硬件的差异使得实现比较复杂,因此我们需要一个帮助我们屏蔽平台和硬件差异的多线程的设计,这就是Job System。
Job System原本是Unity引擎的内部模块,后面多线程的需求多了,就干脆封装了一下提供给大家使用了。它屏蔽了直接对线程的操作,而是把异步逻辑封装成一个个“Job”,由引擎来调度和分配给合适的线程去执行,由引擎来控制线程与CPU核数的对应关系,达到最优的运行效率。
最简单的一个Job转自Unity手册:
// Job adding two floating point values together
public struct MyJob : IJob
{
public float a;
public float b;
public NativeArray<float> result;
public void Execute()
{
result[0] = a + b;
}
}
Job是一个struct,并且需要实现IJob接口
// Create a native array of a single float to store the result. This example waits for the job to complete for illustration purposes
NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);
// Set up the job data
MyJob jobData = new MyJob();
jobData.a = 10;
jobData.b = 10;
jobData.result = result;
// Schedule the job
JobHandle handle = jobData.Schedule();
// Wait for the job to complete
handle.Complete();
// All copies of the NativeArray point to the same memory, you can access the result in "your" copy of the NativeArray
float aPlusB = result[0];
// Free the memory allocated by the result array
result.Dispose();
新建一个Job,调用Schedule()后即把任务放入等待队列里等候调度,同时返回一个handle,可以选择等待Job完成也可以选择继续干别的事情。
需要注意的是Job的成员类型必须是“Blittable Types”或者NativeContainer,Blittable Type的意思就是托管与非托管代码间交互不需要转换的类型,比如float属于System.Single类型,C#和C++对float的定义是一致的,不需要转换。但是bool属于System.Boolean,C#和C++的定义是不一致的,C++只认0/1,所以需要转换。哪些类型属于Blittable如果不清楚的可以查询这里。至于这个限制,是为了防止多线程冲突,Blittable类型的数据都可以直接拷贝而不会有并发访问的问题。NativeContainer是Unity底层提供的容器,Unity对这些容器做了处理,可以直接memcpy拷贝,所以也能够使用。
另外除了IJob以外,还有IJobParallelFor,用来在一个Job里处理批量数据的
struct IncrementByDeltaTimeJob: IJobParallelFor
{
public NativeArray<float> values;
public float deltaTime;
public void Execute (int index)
{
float temp = values[index];
temp += deltaTime;
values[index] = temp;
}
}
还有IJobParallelForTransform,顾名思义就是特意做给Transform用的
ECS+Job System在ECS的结构中,xxxSystem是一个每帧在OnUpdate里执行逻辑的类,当OnUpdate的逻辑太多,我们就可以考虑把这部分逻辑封装成一个Job,丢到另外的线程去执行。针对ECS,Unity提供了一个JobComponentSystem实现多线程的系统。
这就是Demo修改过后的GravitySystem
using Unity.Entities;
using Unity.Transforms;
using Unity.Jobs;
using UnityEngine;
public class GravityJobSystem : JobComponentSystem
{
public static float G = -20;
public static float topY = 20f;
public static float bottomY = -100f;
struct GravityJob : IJobProcessComponentData<GravityComponentData, Position>
{
//运行在其它线程中,需要拷贝一份GravityJobSystem的数据
public float G;
public float topY;
public float bottomY;
//非主线程不能使用Time.deltaTime
public float deltaTime;
//非主线程不能使用UnityEngine.Random
public Unity.Mathematics.Random random;
//物理运算
public void Execute(ref GravityComponentData gravityData, ref Position positionData)
{
if (gravityData.delay > 0)
{
gravityData.delay -= deltaTime;
}
else
{
Vector3 pos = positionData.Value;
float v = gravityData.velocity + G * gravityData.mass * deltaTime;
pos.y += v;
if (pos.y < bottomY)
{
pos.y = topY;
gravityData.velocity = 0f;
gravityData.delay = random.NextFloat(0, 10);
}
positionData.Value = pos;
}
}
}
//每帧生成任务丢给Job System
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
GravityJob job = new GravityJob()
{
G = G,
topY = topY,
bottomY = bottomY,
deltaTime = Time.deltaTime,
random = new Unity.Mathematics.Random((uint)(Time.time * 1000 + 1)),
};
return job.Schedule(this, inputDeps);
}
}
IJobProcessComponentData提供了IComponentData对应的IJob封装,只要是实现这个接口的Job,引擎层会自动实现[Inject]的功能,根据泛型筛选出对应的组件注入,并且一个个传入调用Execute。值得注意的是,IJobProcessComponentData只有T0~T3四种泛型,也许Unity的意思是如果需要注入超过4个组件的,这个系统的设计就不太合理了。
优化后的结果:
8w个球可以跑在60~70FPS之间,我们还可以尝试下推进到10w个球球
看起来10w个差不多到极限了
更多的优化:
- 尝试过启用Burst编译,然而并没有看到更好的效果,不知是用得不对还是瓶颈不在这
- 任务管理器里看到Unity启动后所有核都同时上升,可以看出Job System把一个Job也均衡分布到每个核上了,但每个核都只占50%,暂且未知是系统或者硬件限制还是Unity的优化不足,或者有什么方式可以继续往上面增加任务
总结
- ECS帮助我们解决CPU对内存的访问效率,以及规避了面向对象中继承的弊端
- Job System帮助我们用异步的方式解决单核运算量不足,以及多线程多核负载均衡的问题