Try   HackMD

掌握 Unity JobSystem 體驗多執行續的神奇力量 (二)-以 JobSystem 實作鳥群模擬算法

上一篇文章針對 Unity JobSystem 做了簡單介紹,算是最基本的名詞解釋與幾個常見用法,比較像這個系列的前言。此篇文章將會分別使用不同的方法實作鳥群模擬算法,比較實作上的差異還有效能數據,大量的程式碼範例讓同學們看完就能學會如何使用 JobSystem。

大綱

實作鳥群模擬算法

鳥群模擬最早是由 Craig Reynolds 在 1987 年提出,Flocks, herds and schools: A distributed behavioral model 論文裡面提到鳥群中的個體移動行為可以由 Separation、Alignment、Cohesion 三個向量組成,此模擬算法同樣可以套用到魚群或是牛群、羊群等群體的行為上。
Github 上可以找到 keijiro 大大製作的 Boids 鳥群模擬專案,當群體中的個體數量提高時,數量龐大的計算行為將會對 CPU 帶來負擔,因此這個章節將會拿該專案來嘗試導入 JobSystem,看看是否能改善效能問題。

不使用 Job

原專案將計算向量與移動的行為寫在 BoidBehaviour 腳本中的 Update() 方法中,並將腳本掛到個體(以下稱呼為 agent)上,當生成 agent 的數量上升時,大量的 Update call 也會對效能產生影響。
為了後續的效能比較基準,我們先做一些初步調整,將每個 agent Update() 裡的計算統一放到 BoidController 的一個 Update() 裡面,使用 for 迴圈為每個 agent 進行計算並移動他們。

public float neighborDist = 2.0f; public float rotationCoeff = 4.0f; public float velocity = 6.0f; public float velocityVariation = 0.5f; Transform[] boidsTransform; public GameObject Spawn(){/*生成 agent*/} Vector3 GetSeparationVector(Vector3 p_current, Vector3 p_targetPos) {/*計算 Separation 向量*/} private void Start() { boidsTransform = new Transform[spawnCount]; for(var i = 0; i < spawnCount; i++) { boidsTransform[i] = Spawn().transform; } } private void Update() { for(int i = 0; i < boidsTransform.Length; i++) { var currentPosition = boidsTransform[i].position; var currentRotation = boidsTransform[i].rotation; var noise = Mathf.PerlinNoise(Time.time, noiseOffset) * 2.0f - 1.0f; var vel = velocity * (1.0f + noise * velocityVariation); var separation = Vector3.zero; var alignment = transform.forward; var cohesion = transform.position; int neighborCount = 0; for(int t = 0; t < boidsTransform.Length; t++) { if(t == i) { neighborCount++; continue; } if(Vector3.Distance(boidsTransform[t].position, currentPosition) < neighborDist) { separation += GetSeparationVector(currentPosition, boidsTransform[t].position); alignment += boidsTransform[t].forward; cohesion += boidsTransform[t].position; neighborCount++; } } float avg = 1.0f / neighborCount; alignment *= avg; cohesion *= avg; cohesion = (cohesion - currentPosition).normalized; var direction = separation + alignment + cohesion; var rotation = Quaternion.FromToRotation(Vector3.forward, direction.normalized); if(rotation != currentRotation) { var ip = Mathf.Exp(-rotationCoeff * Time.deltaTime); boidsTransform[i].rotation = Quaternion.Slerp(rotation, currentRotation, ip); } boidsTransform[i].position = currentPosition + boidsTransform[i].forward * (vel * Time.deltaTime); } }

這個算法在 Update() 裡面使用一個大的 for 迴圈針對每個 agent 來做行為的模擬。開銷最大的地方是在 30~44 行的小 for 迴圈,它是用來尋訪所有的 agent 並判斷是否屬於鄰居,若是的話則進行向量的計算,這樣的算法便是 N2 的複雜度,可想而知當 agent 數量上升會對效能帶來非常大的負面影響。

使用 IJob

首先建立一個實作 IJob 的 struct,基本上就是把算法整個搬進 Execute() 方法內,並將會用到的參數宣告出來,讓主執行緒建立 job 時丟數值進來(尤其像是 Time.deltaTime 這種 Unity 的 API 無法在其他執行緒呼叫)。算法上邏輯一模一樣,唯一的差別在於 job 內不能直接使用 Transform,所以我們需要透過 NativeArray<Vector3>NativeArray<Quaternion> 分別來操作位置與旋轉。

struct CaculateTransformJob : IJob { public NativeArray<Vector3> boidPositions; public NativeArray<Quaternion> boidRotations; [ReadOnly] public float noise; [ReadOnly] public Vector3 controllerFoward; [ReadOnly] public Vector3 controllerPosition; [ReadOnly] public float neighborDist; [ReadOnly] public float rotationCoeff; [ReadOnly] public float deltaTime; [ReadOnly] public float velocity; [ReadOnly] public float velocityVariation; Vector3 GetSeparationVector(Vector3 p_current, Vector3 p_targetPos){...} public void Execute() { for(int index = 0; index < boidPositions.Length; index++) { var currentPosition = boidPositions[index]; var currentRotation = boidRotations[index]; var vel = velocity * (1.0f + noise * velocityVariation); var separation = Vector3.zero; var alignment = controllerFoward; var cohesion = controllerPosition; int neighborCount = 0; for(int i = 0; i < boidPositions.Length; i++) { if(i == index) { neighborCount++; continue; } if(Vector3.Distance(boidPositions[i], currentPosition) < neighborDist) { separation += GetSeparationVector(currentPosition, boidPositions[i]); alignment += (boidRotations[i] * Vector3.forward); cohesion += boidPositions[i]; neighborCount++; } } float avg = 1.0f / neighborCount; alignment *= avg; cohesion *= avg; cohesion = (cohesion - currentPosition).normalized; var direction = separation + alignment + cohesion; var rotation = Quaternion.FromToRotation(Vector3.forward, direction.normalized); if(rotation != currentRotation) { var ip = Mathf.Exp(-rotationCoeff * deltaTime); rotation = Quaternion.Slerp(rotation, currentRotation, ip); } boidRotations[index] = rotation; boidPositions[index] = currentPosition + (boidRotations[index] * Vector3.forward) * (vel * deltaTime); } } }

程式開始時一樣生成 agent。
Update() 方法裡要先將鳥群的 Transform 資訊準備成 blittable 類型的陣列來建立 TempJob 類型的 NativeArray,接著建立 job 把資料塞進去,最後呼叫 Schedule()
LateUpdate() 方法裡先呼叫 Complete() 保證 job 執行完畢後,就可以拿 NativeArray 的資料來設定 agent 的位置與旋轉,最後要記得將 NativeArray 釋放掉。

private void Start() { boidsTransform = new Transform[spawnCount]; for(var i = 0; i < spawnCount; i++) { boidsTransform[i] = Spawn().transform; } } private void Update() { for(int i = 0; i < spawnCount; i++) { positionVectors[i] = boidsTransform[i].position; rotationQuaternions[i] = boidsTransform[i].rotation; } positionList = new NativeArray<Vector3>(positionVectors, Allocator.TempJob); rotationList = new NativeArray<Quaternion>(rotationQuaternions, Allocator.TempJob); var _caculateTransformJob = new CaculateTransformJob { boidPositions = positionList, boidRotations = rotationList, noise = Mathf.PerlinNoise(Time.time, Random.value * 10.0f) * 2.0f - 1.0f, controllerFoward = transform.forward, controllerPosition = transform.position, neighborDist = neighborDist, rotationCoeff = rotationCoeff, deltaTime = Time.deltaTime, velocity = velocity, velocityVariation = velocityVariation }; caculateTransformHandle = _caculateTransformJob.Schedule(); } private void LateUpdate() { caculateTransformHandle.Complete(); for(int i = 0; i < spawnCount; i++) { boidsTransform[i].position = positionList[i]; boidsTransform[i].rotation = rotationList[i]; } positionList.Dispose(); rotationList.Dispose(); }

這個做法能成功的將計算的邏輯分派給某個 worker thread 執行。大家可能會覺得這個作法只是把計算工作搬到其他執行緒而已,所以效能會和不使用 job 的方法差不多,其實不對,不使用 job 的作法在計算工作裡面大量的存取 Transform,這其實是會有一定開銷的;而 job 裡面都只有拿 Vector 與 Quaternion 做計算,因此效能上 job 的做法還是會好出一些。

使用 IJobFor

如前面所介紹,IJobFor 是一個會在內部用 for 迴圈呼叫每個 Excute(int index) 的 job。實作 IJobFor 其實和 IJob 很像,只要將原本的 Excute() 改寫成 Excute(int index),將方法內自己寫的大 for 迴圈拔掉即可,其他的程式碼都一樣。

struct CaculateTransformJob : IJobFor { ... public void Execute(int index) { //for(int index = 0; index < boidPositions.Length; index++){ ... //} } }

主執行緒的部分也幾乎一模一樣,只有在呼叫 Schedule() 方法時要多帶入 for 迴圈的長度,讓 job 可以從 Execute(0) 執行到 Execute(長度-1)
IJobFor 的 Schedule() 方法還要強制傳入 dependency 參數,但我們的範例中不需要相依其他 job,所以傳入 default 即可。

private void Update() { ... caculateTransformHandle = _caculateTransformJob.Schedule(spawnCount, default); }

由於算法和前面做法都相同,和 IJob 比起來也只差在 IJobFor 自動幫忙進行 for 迴圈而已,所以效能表現上和 IJob 的作法是差不多的。

使用 IJobParallelFor

前面的做法都只是把所有的計算任務塞給某一個執行緒獨立執行,而 IJobParallelFor 才是真正能將任務分配給數個執行緒平行執行的 job。它和 IJobFor 一樣都是內部用 for 迴圈呼叫每個 Excute(int index),只不過 IJobParallelFor 會把每個 Excute(int index) 平均分配給 worker thread。實作方式和 IJobFor 一模一樣,只要改成 IJobParallelFor 介面即可。

struct CaculateTransformJob : IJobParallelFor { ... public void Execute(int index) { //for(int index = 0; index < boidPositions.Length; index++){ ... //} } }

主執行緒的部分也和 IJobFor 一樣,只有在呼叫 Schedule() 方法時要多帶入 Batch Size,後面會在詳細介紹 Batch Size,它主要影響 IJobParallelFor 內部拆分 job 時的分散程度,範例先填入 1。

private void Update() { ... caculateTransformHandle = _caculateTransformJob.Schedule(spawnCount, 1, default); }

調整完運行起來會發現,竟然跳出錯誤訊息!那是因為我們對 NativeArray 同時進行寫入與讀取了,同學們可以觀察 Execute(int index) 方法裡面的 boidPositions,在方法中間會讀取 boidPositions 所有 index 的數值,而在方法最後面卻會對某個 index 進行寫入,又因為好多個 Execute(int index) 方法是分散在多個執行緒裡面同時執行,於是 JobSystem 的 Safety Check 機制就會跳出錯誤警告我們。
這種情況我們只要簡單的將讀取與寫入的 NativeArray 分開來就能解決。所以我們將原本的 NativeArray 標記成 [ReadOnly],並在 job 裡面添加代表結果的 NativeArray 並標記 [WriteOnly]
Execute(int index) 方法的最後就是將結果寫進我們新增的 NativeArray。

struct CaculateTransformJob : IJobParallelFor { [ReadOnly] public NativeArray<Vector3> boidPositions; [ReadOnly] public NativeArray<Quaternion> boidRotations; [WriteOnly] public NativeArray<Vector3> resultPositions; [WriteOnly] public NativeArray<Quaternion> resultRotations; ... public void Execute(int index) { ... resultRotations[index] = rotation; resultPositions[index] = currentPosition + (boidRotations[index] * Vector3.forward) * (vel * deltaTime); } }

在主執行緒裡面同樣添加代表結果的 NativeArray,仿照原本的模式建立成 TempJob 類型,並且在建立 job 時設定進去。最後要更新 agent 的位置時就拿代表結果的 NativeArray 來設定即可。

NativeArray<Vector3> positionList; NativeArray<Quaternion> rotationList; NativeArray<Vector3> resultPositionList; NativeArray<Quaternion> resultRotationList; private void Update() { positionList = new NativeArray<Vector3>(positionVectors, Allocator.TempJob); rotationList = new NativeArray<Quaternion>(rotationQuaternions, Allocator.TempJob); resultPositionList = new NativeArray<Vector3>(spawnCount, Allocator.TempJob); resultRotationList = new NativeArray<Quaternion>(spawnCount, Allocator.TempJob); var _caculateTransformJob = new CaculateTransformJob { boidPositions = positionList, boidRotations = rotationList, resultPositions = resultPositionList, resultRotations = resultRotationList, ... } caculateTransformHandle = _caculateTransformJob.Schedule(spawnCount, 1, default); } private void LateUpdate() { caculateTransformHandle.Complete(); for(int i = 0; i < spawnCount; i++) { boidsTransform[i].position = resultPositionList[i]; boidsTransform[i].rotation = resultRotationList[i]; } positionList.Dispose(); rotationList.Dispose(); resultPositionList.Dispose(); resultRotationList.Dispose(); }

這個作法是真正的把計算任務分配到多個執行緒裡同時執行,因此能夠大大的降低主執行緒等待的時間,從而提升效能表現!

使用 IJobParallelForTransform

不知道大家會不會跟我有一樣的感覺,那就是非主執行不能使用 Unity 的 Transform 好麻煩啊!在大部分的遊戲開發情境中,我們總會需要控制各種遊戲物件的位置和旋轉,如果場景中有大量物件,我們又想利用多執行緒來提高效能,只能像上面那要繞來繞去的把 position 和 rotation 等資訊傳遞給其他執行緒,這樣真的頗不方便。
大家的煩惱 Unity 也有注意到,於是就有了 IJobParallelForTransform 這個介面,它就好像是一個可以使用 Transform 資訊的 IJobParallelFor,Unity 將 Transform 資訊包裝成 TransformAccess 的 struct 讓這個 job 可以使用,長相如下:

struct SetTransformJob : IJobParallelForTransform
{
    public NativeArray<Vector3> positions;
    public NativeArray<Quaternion> rotations;

    public void Execute(int index, TransformAccess transform)
    {
        positions[index] = transform.position;
        rotations[index] = transform.rotation;
    }
}

接著我們就拿這個 job 來套用鳥群模擬算法,可以看到我們這邊就不需要多使用代表結果的 NativeArray 了,並且可以把計算結果直接設定到 TransformAccess 裡面,其他的部分都和前面算法一樣。

struct CaculateTransformJob : IJobParallelForTransform { [ReadOnly] public NativeArray<Vector3> boidPositions; [ReadOnly] public NativeArray<Quaternion> boidRotations; ... public void Execute(int index, TransformAccess transform) { ... transform.rotation = rotation; transform.position = boidPositions[index] + (boidRotations[index] * Vector3.forward) * (vel * deltaTime); } }

至於主執行緒的部分,由於我們不再需要每個 Update() 都重新把當下的鳥群 Transform 資訊轉換成 job 能使用的 NativeArray,因此我們可以稍作一點優化。我們直接在 Start() 方法裡建立 Persistent 類型的位置與旋轉 NativeArray,並且把生成出來的鳥群 Transform 拿來建立成 TransformAccessArray。

private NativeArray<Vector3> positionList; private NativeArray<Quaternion> rotationList; private TransformAccessArray boidsTransformArray; private void Start() { positionList = new NativeArray<Vector3>(spawnCount, Allocator.Persistent); rotationList = new NativeArray<Quaternion>(spawnCount, Allocator.Persistent); Transform[] _boidsTransform = new Transform[spawnCount]; for(var i = 0; i < spawnCount; i++) { _boidsTransform[i] = Spawn().transform; } boidsTransformArray = new TransformAccessArray(_boidsTransform); }

接下來在 Update() 方法裡,我們就可以透過一個 SetTransform 的 job,搭配上我們算法的 job 來實現鳥群模擬。SetTransformJob 專門用來將鳥群的 transform 資訊設定到 NativeArray 裡面(所以標上 [WriteOnly]),主要算法的 job 就能拿到設定好 transform 資訊的 NativeArray 來進行計算。在 Schedule 的呼叫上,兩個 job 都要把 TransformAccessArray 作為參數傳遞進去,並且互相將自己的 JobHandle 傳給對方進行依賴。最後再 LateUpdate() 方法中呼叫 Complete() 即可。

struct SetTransformJob : IJobParallelForTransform { [WriteOnly] public NativeArray<Vector3> boidPositions; [WriteOnly] public NativeArray<Quaternion> boidRotations; public void Execute(int index, TransformAccess transform) { boidPositions[index] = transform.position; boidRotations[index] = transform.rotation; } } private void Update() { var _setTransformJob = new SetTransformJob { boidPositions = positionList, boidRotations = rotationList }; var _setTransformHandle = _setTransformJob.Schedule(boidsTransformArray, caculateTransformHandle); var _caculateTransformJob = new CaculateTransformJob { boidPositions = positionList, boidRotations = rotationList, ... }; caculateTransformHandle = _caculateTransformJob.Schedule(boidsTransformArray, _setTransformHandle); } private void LateUpdate() { caculateTransformHandle.Complete(); }

這個做法讓我們可以簡單地將 Unity 的 Transform 丟給 job 進行操作,儘管多出了另外一個 job ,但它的工作原本就是主執行緒在做的事,所以效能表現上和 IJobParallelFor 差不多。

使用 C# Thread

或許有些人會好奇,在沒有 JobSystem 之前,難道 C# Thread 做不到嗎?我這邊也有做過一些嘗試,但礙於篇幅就不介紹實作方式了,這邊提出幾個實作上遇到的問題以及與 job 的差異。

  • 剛開始沒有思考太多,直接在 Start 的時候針對每一個 agent new 出一個 thread。生成 300 個 agent 時電腦差不多就當掉了(JobSystem 生成 5000 agent 還能維持 30 FPS),這時候才想到這麼多執行緒同時要搶占電腦資源似乎不太合理
  • 先將 agent 數量降低到 250,對應 new 出 250 個執行緒,電腦是能夠順跑的,FPS 大約落在 60~100 之間,但運行起來的 agent 會有前後晃動的現象。主要大概是因為沒有考慮到資料同步的問題,還沒等到所有 agent 都計算完就已經在執行更新 agent 的位置。
  • 考量到執行緒一次不能 new 太多,以及資料同步問題,因此調整了做法,使用 C# 的 ThreadPool 類讓系統自行分配執行緒,並且利用 WaitHandle 確實等到計算完成後才更新 agent 的位置。解決了前後晃動的問題,但 250 隻 agent 的效能表現只有 30 FPS 多一點。

總的來說我自己認為 C# 的 Thread 方法比起 JobSystem 實在難用太多了。

從最基本的平行處理,JobSystem 提供 IJobParallelFor 之類的介面讓我們輕鬆達成;而 Thread 只能自行手動分配執行緒。

在資料同步方面,JobSystem 的 Schedule() 方法讓我們輕鬆建立 Job 間的依賴關係;而 Thread 只能自行使用 Join() 等方法來保證前面的執行緒先執行完畢後再往下繼續。

另外還有資料讀寫的衝突,JobSystem 有針對資料的 Read/Write 標籤可以用,並且會在有衝突時跳出錯誤訊息;而 Thread 需要自行將資料 lock 好,有錯的話還不容易看出來錯在哪。

再來就是效能的部分,JobSystem 搭配上 Burst Compiler 可以達到很好的表現;而 Thread 可能真的要在很熟悉如何使用的條件下搭配上優良的算法才有可能達到 JobSystem 的效果(這邊想徵求熟悉 Thread 的大神可以分享一下怎麼寫比較好:stuck_out_tongue_winking_eye:)。

效能比較

看完了大量的程式碼(或者根本沒看),相信同學們都很好奇這些做法之間比較詳細的效能差異,下面就來給各位展示一下。

各種 Job 效能展示

以下都是在 300 隻 agent 的相同環境下進行測試。

  • 不使用 Job (點我回顧實作)
    [gif]
    [profiler]
    CPU 執行時間大約落在 20~23ms,FPS 大約是 45~50。
    從 profiler 可以看到,主執行緒的 Update() 方法就花了 13.8ms,而其他的 worker thread 都是閒置的。
  • 使用 IJob (點我回顧實作)
    [gif]
    [profiler]
    CPU 執行時間大約落在 15~17ms,FPS 大約是 60~66。
    從 profiler 可以看到,計算的任務被分配給 Worker 1,花了 7.69ms,主執行緒的 LateUpdate() 裡面就相應的花了 7.49ms 等 job 的 Complete()
  • 使用 IJobFor (點我回顧實作)
    [gif]
    [profiler]
    CPU 執行時間大約落在 15~17ms,FPS 大約是 60~66。
    和前面介紹的一樣,效能表現和 IJob 的作法差不多。
  • 使用 IJobParallelFor (點我回顧實作)
    [gif]
    [profiler]
    CPU 執行時間大約落在 7~9ms,FPS 大約是 120 上下。
    從 profiler 可以看到,計算的任務被分配給多個 Worker,主執行緒的 LateUpdate() 裡面只花了 1.14ms 等 job 的 Complete()
  • 使用 IJobParallelForTransform (點我回顧實作)
    [gif]
    [profiler]
    CPU 執行時間大約落在 7~9ms,FPS 大約是 120 上下。
    和前面介紹的一樣,效能表現和 IJobParallelFor 的作法差不多,不過在 profiler 可以觀察到 Worker 的計算工作前面有一個小小的藍色區塊,那就是此作法多出來的另外一個 job。

套用 BurstCompile

細心的同學們應該有注意到文章中非常偶爾的會提到 Burst,關於 Burst 是什麼,我們會專門做一期影片和大家介紹我們會在下一篇文章中和大家討論,簡單說它就是一個能讓 job 效能更好的東東。
它的用法就只要在 job 前面加上 [BurstCompile] 的標籤:

[BurstCompile]
struct MyJob : IJob {...}

只要加上標籤就好,是不是非常簡單!
在 profiler 上面會看到使用 Burst 的 job 會變成綠色的區塊。
[圖片]
接下來我就不貼圖片了,直接上個表格幫大家比較一下有沒有 Busrt 對效能的影響:

300 Agent No Burst Burst
IJob 14.45 ms 6.87 ms
IJobFor 15.21 ms 7.06 ms
IJobParallelFor 8.10 ms 6.56 ms
IJobParallelForTransform 7.99 ms 6.29 ms
1000 Agent No Burst Burst
IJob 81.43 ms 9.61 ms
IJobFor 85.50 ms 9.91 ms
IJobParallelFor 29.41 ms 8.12 ms
IJobParallelForTransform 24.60 ms 7.78 ms

表格中簡單的數據應該不難看出來 Burst 到底有多強大,有在使用 JobSystem 的同學記得不要忘記順手加一下喔:smile:

Thread 的效能表現

最後不免俗的反手捅一下 Thread。
在 300 Agent 的情境下,CPU 的執行時間大約為 34.72 ms。
而在 1000 Agent 的情境下,CPU 的執行時間天殺的來到 314.47 ms。
在這邊還是想要請求會寫 Thread 的大大分享一下要怎麼寫出高效率的多執行緒程式碼:sweat_smile:

後記

看完這篇相信大家都已經能夠用 JobSystem 寫出自己的多執行緒程式碼了,下一篇文章會再詳細介紹 JobSystem 裡面的各種小細節與觀念,有興趣深入了解的小夥伴們可以稍稍期待一下。