[Unity]利用C# Job System與Burst Compiler來解放CPU的效能
這是我最早在2020時寫的文章,滿多地方講得不夠清楚,我後來寫了兩篇關於多執行緒程式設計與C# Job System的文章,更適合用來學習Job system,可以的話請先參考這兩篇文章
在寫遊戲時,我們時常必須為了兼顧遊戲運行時的效能而縮小遊戲的scale,像是物件的數量、渲染的距離,也可能是模擬的頻率。
在這篇文章,我會示範如何改寫一個鳥群移動(Flocking)的腳本,透過Job System與Burst Compiler來優化,使得runtime的鳥群數量增加的同時,還能讓cpu處理時間更短。
首先,我們先來看鳥群移動的腳本,現在遊戲中常用來模擬鳥類、魚類群體移動的規則是Craig Reynolds在1987年提出,在Boids這支程式內所使用。網路上有各種版本的實作,這次我直接拿大神Kejiro在2014年的Boids專案的腳本來當基底修改。
Craig Reynolds提出的規則中群體的移動主要由三個向量組成:
Separation — 迴避鄰近的個體(一種對鄰近群體短距離的排斥力)
Alignment — 會逐漸面向鄰近個體的平均方向
Cohesion — 會逐漸靠向鄰近個體的平均位置(一種對群體長距離的吸引力)
Kejiro的實作將這部分的邏輯寫在專案內的Boid Behaviour這個腳本內:
Scene上總共會有N個掛有Boidbehaviour的物件,這裡就稱呼該物件為Agent.
Demo scene跑起來如下圖:
上圖的agent數量為30,看起來沒什麼問題,Editor還是在150fps以上。但是當我們試著把agent數量調至100呢?
提高agent數量後,cpu的處理時間變長了,fps降至90左右,也許我們可以僥倖地想說:「fps90應該還可以吧?」,但我的scene內可能需要同時有好幾個鳥群或大量的魚群在移動。如果今天我們把數量提升到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數。
透過profiler監測的結果我們知道看起來是跟Physics的api call相關的地方,在把L71-L81都註解掉之後,再用profiler監測一次,整個update的處理時間會從20ms下降至趨近於1.36ms!
我們可以確定整支程式的瓶頸就是在這裡,來分析一下這幾行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();}
執行現有程式碼的結果:
與沒有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) -> 18ms1000個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比也是有一定的幫助。
利用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的資源。
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