[Unity]利用C# Job System與Burst Compiler來解放CPU的效能

Eric Hu
Akatsuki Taiwan Technology
17 min readJan 7, 2021

這是我最早在2020時寫的文章,滿多地方講得不夠清楚,我後來寫了兩篇關於多執行緒程式設計與C# Job System的文章,更適合用來學習Job system,可以的話請先參考這兩篇文章

淺談多執行緒程式設計與Unity的C# Job System

開始使用Unity的C# Job System(一)

在寫遊戲時,我們時常必須為了兼顧遊戲運行時的效能而縮小遊戲的scale,像是物件的數量、渲染的距離,也可能是模擬的頻率。

在這篇文章,我會示範如何改寫一個鳥群移動(Flocking)的腳本,透過Job System與Burst Compiler來優化,使得runtime的鳥群數量增加的同時,還能讓cpu處理時間更短。

Auklet_flock_Shumagins_1986
群體行動的海雀

首先,我們先來看鳥群移動的腳本,現在遊戲中常用來模擬鳥類、魚類群體移動的規則是Craig Reynolds在1987年提出,在Boids這支程式內所使用。網路上有各種版本的實作,這次我直接拿大神Kejiro在2014年的Boids專案的腳本來當基底修改。

Craig Reynolds提出的規則中群體的移動主要由三個向量組成:

Separation — 迴避鄰近的個體(一種對鄰近群體短距離的排斥力)

Alignment — 會逐漸面向鄰近個體的平均方向

Cohesion — 會逐漸靠向鄰近個體的平均位置(一種對群體長距離的吸引力)

Kejiro的實作將這部分的邏輯寫在專案內的Boid Behaviour這個腳本內:

Scene上總共會有N個掛有Boidbehaviour的物件,這裡就稱呼該物件為Agent.

Demo scene跑起來如下圖:

Agent數量30

上圖的agent數量為30,看起來沒什麼問題,Editor還是在150fps以上。但是當我們試著把agent數量調至100呢?

Agent數量100

提高agent數量後,cpu的處理時間變長了,fps降至90左右,也許我們可以僥倖地想說:「fps90應該還可以吧?」,但我的scene內可能需要同時有好幾個鳥群或大量的魚群在移動。如果今天我們把數量提升到300呢?

agent數量300

可以發現cpu的處理時間已經突破30ms,fps甚至會掉到30以下,考慮到遊戲場景不可能只有這個腳本,在實機中為了遊戲順暢度我們目前是不可能把agent數量拉到300的。為了提升支援的agent數,我們要來優化這份程式碼。

那我們該如何優化呢?

如果是對unity有點經驗或者看過10000 Update() calls這篇官方blog的人,可能會想先把每個agent各自的BoidBehaviour.update()合併起來,在一個manager內存一個agent的array,將BoidBehaviour.update內的運算程式都搬到manager內,並在manager自己的update裡面一次更新所有agent的狀態。

這絕對是有幫助的,尤其是agent的數量超過3、5000時。但僅僅是這樣還是不夠的,不用說3000個agent,,光500個agent我的macbook pro就吃不消了,目前,單純的合併update並無法讓這支程式順暢的執行我需要的agent數。

可以看到在agent數量為300時,cpu的處理時間就已經非常長了,而且還伴隨著驚人的GC。

透過profiler監測的結果我們知道看起來是跟Physics的api call相關的地方,在把L71-L81都註解掉之後,再用profiler監測一次,整個update的處理時間會從20ms下降至趨近於1.36ms!

註解掉L71-L81的結果

我們可以確定整支程式的瓶頸就是在這裡,來分析一下這幾行code:

var nearbyBoids = Physics.OverlapSphere(.....)foreach (var boid in nearbyBoids){    if (boid.gameObject == gameObject) continue;    var t = boid.transform;    separation += GetSeparationVector(t);    alignment += t.forward;    cohesion += t.position;}

計算移動的三個向量(separation、alignment、cohesion)必須要取得neighbor agents各自的position跟forward,判斷是否是neighbor則是依靠Physics.OverlapSphere 來找到範圍內的所有collider。

Physics.OverlapSphere():當場上的collider一多,gc就會暴增,
GetSeparationVector()內的transform.position還有boid.transform,這些呼叫在數量堆疊起來的時候也給了cpu非常大的負擔。

既然找到瓶頸了,接下就要用C# Job system並且新增一個TranformJob來實作IJobParallelForTransform,用來改寫這份程式碼。

Job System?

Unity在2018版之後推出了C# Job system,Job System是一個提供給Unity使用者的Multi-Thread方案,特色是不會有race condition,支援平行處理,可用Handle做不同Job間的依賴與排程。

還有一點很重要的是job內只支援使用value type的變數與native container(為native memory),使用native container不會產生GC的同時,在使用完畢後也必須要手動釋放記憶體。

關於Job System的詳細說明可以看官方手冊

IJobParallelFor?

Job System會將實作IJobParallelFor的Job盡可能地去平行處理,假設我們設定Parallel長度為100,batch size設為10,Job System就會將index 0–9、 10–19、20–29…etc分配給不同的worker thread去排程與執行Execute函式。我們可以透過index來對固定長度的native container的成員做相同的運算。

Job會在main thread以外的thread執行,所以只能在main thread呼叫的Unity API都是被禁用的,包括像是Time.deltaTime,Physics.XXX。也不能傳reference進去,所以像是boidBehaviour L67, L68的

var alignment = controller.transform.forward;        
var cohesion = controller.transform.position;

都是不能直接呼叫的,我們可以將這些計算時必須用到的資料在一開始new這個job時就定義好。

將每個agent的同種資料都存在同一個NativeArray內,比如說BoidPositons即為所有agent的position,BoidRotations即為所有agent的rotation,
這種將資料從class內抽離出來,統一計算的方式即為較Data Oriented的寫法,看似有點麻煩,不太直覺,但其實對cpu來說是比較親切的(詳細就在別篇文章再說)。

private void Update(){
.....
.....
m_transJob = new TransformJob()
{
BoidVelocities = m_boidVelocities,
BoidPositions = m_boidPositions,
BoidRotations = m_boidRotations,
ControllerFoward = transform.forward,
ControllerPosition = transform.position,
RotationCoeff = rotationCoeff,
DeltaTime = Time.deltaTime,
NeighborDist = neighborDist,
Speed = velocity,
};
.....
.....
}

public struct TransformJob : IJobParallelForTransform
{
[ReadOnly]
public NativeArray<Vector3> BoidPositions;

[ReadOnly]
public NativeArray<Quaternion> BoidRotations;

[ReadOnly]
public Vector3 ControllerFoward;

[ReadOnly]
public Vector3 ControllerPosition;

[ReadOnly]
public float RotationCoeff;

[ReadOnly]
public float DeltaTime;

[ReadOnly]

public float NeighborDist;

[ReadOnly]
public float Speed;
public void Execute(int index, TransformAccess trans)
{
..........
..........
..........
}

}

最後將BoidBehaviour內的移動邏輯搬到Execute內,區別是我們不再用transform跟gameobject的reference指定agent,而是用index來取得array內,屬於第N個agent的資料

Physic.OverlapSphere無法在job內使用,為了找出neighbor agents,
我用了一個非常暴力的計算:計算當前agent與其他所有agent的距離,距離在範圍內的就視為neighbor agent。

這是個沒效率的方法,如果有N個agent,總共會計算距離N*N次,但有Burst Compiler的話,這個計算變成是一個可行的方案。

Burst Compiler?

Burst Compiler是一個將我們的部份程式碼轉換為高度優化的native code的編譯器,是unity的DOTS裡很重要的一環,Job上方只要有加上[BurstCompile]attribute 就可以讓burst去轉換這份Job的程式碼。

詳見官方文件

原本單純的位移也改為用velocity+acceleration來計算,所以我們會需要另一個NativeArray來儲存每個agent的velocity。
值得注意的是BoidPositons跟BoidRotations是[ReadOnly]的,原因是不同的index會在不同的worker thread執行,如果在這個Job內去更改這兩個array內的值,即會有race condition(Unity會幫我們跳錯),我們必須在transformJob開始前,schedule另一個job去更新這兩個array內的值。

[BurstCompile]public struct UpdateArrayJob : IJobParallelForTransform{
[WriteOnly]
public NativeArray<Vector3> BoidPositions;
[WriteOnly]
public NativeArray<Quaternion> BoidRotations;
public void Execute(int index, TransformAccess trans) {
BoidPositions[index] = trans.position;
BoidRotations[index] = trans.rotation; }}

Job都準備好之後就是Schedule並且等待complete。

void Update()
{
.....
m_transJob = new TransformJob()
{
BoidVelocities = m_boidVelocities,
BoidPositions = m_boidPositions,
BoidRotations = m_boidRotations,
ControllerFoward = transform.forward,
ControllerPosition = transform.position,
RotationCoeff = rotationCoeff,
DeltaTime = Time.deltaTime,
NeighborDist = neighborDist,
Speed = velocity,
};
.....
.....
//Schedule呼叫後即會送出job讓job system排程執行
m_updateArrayHandle = m_updateArrayJob.Schedule(m_boidsTransformArray);
m_JobHandle = m_transJob.Schedule(m_boidsTransformArray, m_updateArrayHandle);//JobHandle.Complete()一般來說會放在lateUpdate,
//JobHandle.Complete()會確保相依的job在main thread已經完成,
//我們在complete後可以安全的去存取job內改寫的native array
//我為了方便看profiler的數值,改成在update內最後面呼叫。
m_JobHandle.Complete();}

執行現有程式碼的結果:

300 agents, TransformJob + Burst-Enable

與沒有job相比,我們縮短了97%的cpu處理時間!
甚至比之前註解掉BoidBehaviour L71-L81程式碼測試時的速度還快!(少了300個update call也是有點幫助)。用native container也可以確保不會製造任何GC。

如果拔掉現在agent身上的collider就會更低了,畢竟已經沒有在用physics.OverlapSphere,物件上的collider已經沒有意義。

結果測試

300個agent:IJobParallelForTransform(With Burst)->0.47ms
IJobParallelForTransform(No Burst) -> 8.5ms
300 Update(BoidBehaviour) -> 18ms
1000個agent:IJobParallelForTransform(With Burst) ->4.55ms
IJobParallelForTransform(No Burst) -> 71ms
1000 update(BoidBehaviour) -> 67.9ms

N*N的距離計算還是太暴力了,沒有Burst的話,在1000個agent時就比原本的方法還要慢了,也許可以Partition/Grouping固定數量的agent,或者將agent的座標當成key存在hasmap內再找相鄰的key的方式來優化。不過我目前有大約500個agent在場景內就夠了。這塊就未來有空再去想。

結論

Job System跟Burst推出之後,不只多了一個解決CPU效能瓶頸的選項,因為入門門檻很低(本身的各種使用限制+safety check),也讓Unity的程式普遍來說朝更快,更有效率,Scale更大的方向發展(尤其手機)。是一個我們可以多多利用的工具。

而對我這種從Unity才開始接觸寫遊戲程式的人來說,也是可以練習很多以前不常碰到的觀念,像是Multithread、C# managed type、DO…等等,網路上很多blog對job跟native container也有很精彩的分析,也是讓人大開眼界。

延伸

單單只是做成Job其實還是不夠,下面列出還有三個可以實作的東西,
之後會在其他的Post一一做詳細的介紹,

閃避障礙物

鳥群移動還有一點很重要的是閃避障礙物的行為(Obstacle Avoidance),我們可以利用RaycastCommand還有SpherecastCommand這兩個api在job system內平行化的做raycast跟spherecast來偵測障礙物和計算沒有障礙物的方向,存取hit result至NativeArray內在傳給TransformJob,經過測試這兩種command都沒有GC,平行化的處理在效能上跟一般的api比也是有一定的幫助。

加上Obstacle Avoidance的300個agent,紅色debug線代表的是閃避的向量

利用Mathematics來優化

現在job內的計算我還是用Unity自帶的Vector3, Mathf等等變數類別,
Unity有推出一個Mathematics package,裡面的函式包括了更貼近graphic層常用的lerp、saturate、step…等等, 變數也提供了float3、quaternion這類在SIMD處理資料時,更加優化的資料型態,如果job內改用這包函式庫的話,效能也可以在提升一些(別人的文章測試起來是5%~10%)。

利用GPU Instancing來優化

事實上我們並不需要gameobject本身,因為我們在改寫為job計算時,我們就已經將需要的資料都拆出到不同的array了,我們可以更進一步的優化, 不再spawn game object,並利用DrawMeshInstanced或DrawMeshInstancedIndirect來直接將mesh還有各個agent的position、rotation傳給gpu端,這樣可以節省cpu處理大量頂點資訊還有game object的資源。

透過gpu instacning,scene上也不會有多餘的game object存在。

Referencese 參考資料

Unity Official

Job System Manual
Burst User Guide
On DOTS: C++ & C#
Unity at GDC — C# to Machine Code
Unite Europe 2017 — C# job system & compiler

JacksonDunstan.com

How to Write Faster Code Than 90% of Programmers
Job System Tutorial
C# Tasks vs. Unity Jobs
Free Performance with Unity.Mathematics

[Unite Europe 2017 — C# job system & compiler]
這部影片還有[On DOTS: C++ & C#]我都很建議看,可以了解Unity推出這功能的前因後果,可以當紀錄片看/讀?

對這專案的source code有興趣的可以下載這個Unity package

--

--