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

Eric Hu
18 min readNov 3, 2022

--

上一篇blog,我做了多執行緒程式設計的優缺點以及Job System架構的簡單介紹,這一篇會介紹一下Unity提供的各種不同的Job Interface,以及基本的使用方式。希望看完這篇的人,不只可以寫出多執行緒的程式,也可以寫出一支能夠平行運算的程式。

用Job system還有平行化運算,就是DOTS嗎?

DOTS — data oriented technology stack,是Unity推出的數據導向程式組合包。
包含 : ECS架構、C# Job System、Burst Compiler。
這三者是可以獨立存在的,所以即使專案架構不使用ECS,你仍然可以在任何現有專案使用C# Job System還有burst compiler。

在Unity官方文件中,我們可以看到3種不同的Job Interface— IJob、IJobFor、 IJobParallelFor,這篇blog會在實作一個簡單demo的同時,一一介紹每一個Interface的用法。

左 : Job Package中額外的Job interface,右 : 目前官方文件中的Job interface

雖然這3種都會是非常常用到的Job,但如果稍微翻一下Job Package就會發現上能使用的介面還有好幾種,應該是因為Job還沒正式進入1.0版,所以沒有將所有API納入官方文件中,但裡面有一些介面的功能非常實用,考慮到文長,預計在下一篇介紹。

專有名詞

在正式動手寫code前,快速回顧一下Job system中的一些專有名詞

Job

一個Job就是一個任務的單位,並在woreker thread上執行,就像是一個可以在不同thread上執行的function一樣,不過Job是一個struct。

Job System

job system 內有個manager,根據當下硬體建立出對應的worker thread,然後把準備執行的Job放到自己管理的queue內,並且分配到閒置/適合的worker thread去排隊&執行。Job system同時也管理不同Job之間的相依、先後順序。

Safety System

上一篇blog中有提到多執行緒程式的各種缺點,為了避免遇到其中的race condition,再將資料傳遞給Job時,一律都是傳(value type),來避免不同thread中的程式碼透過reference去修改到同一份資料,這種做法消滅了race condtion的可能性。

Native Container

Safety system 這種只傳不傳reference的方式,會讓每個job內都只有data的copy,而不是原本的data。為了突破這個限制,Unity特別定義了一個在不同的Thread間讀取/儲存的共享記憶體 - Native container。

Native container是一個C#層級的封裝,內部是一個指向unmanaged memory的pointer。透過使用native container,我們可以讓main thread與Job都存取同一份data。針對一些在使用native container時可能出現race condition的code,會額外報錯。預設的Native container有native array, native list, native queue, native hashmap...等等。

要注意的是native container要到的記憶體是unmanaged的,亦即不受C#的GC管理,所以要手動dispose掉。所以Job system還有一個好處就是不會有GC產生。

因為是unmanaged的記憶體,native container只能裝blittable的value type,bool跟char有特別處理算是Unity特規。

NativeContainer Allocator

new每個native container時都會需要附帶一個Allocator的type,Allocator代表這塊記憶體的生命週期,分別有

  • Temp — 生命週期不到一個frame,allocate速度最快,無法再不同Job間傳遞。
  • TempJob — 生命週期4個frame,allocate速度其次
  • Persistent — 沒有生命週期,直到手動dispose前,allocate速度最慢

要注意除了temp外,用完都需要手動dispose native container不然會造成記憶體洩漏。

Job Handle

我們每schedule出一個job後,會回傳一個JobHandle的struct回來。我們可以透過JobHandle知道該job是否完成,也可以把JobHandle當成下一個job schedule時的參數。讓job之間產生相依的關係
(EX: 執行完JobA,JobB才會開始執行)。

常用API - Schedule()

將job交給Job system的manager進行排程,如果是job.schedule() ,manager會分派給非main thread的某個worker thread。如果是job.run() ,則該job會直接在main thread執行。

常用API - Complete()

當schedule job後,一定得等到job執行完,確定沒有其他thread在碰native container的資料時,我們在main thread才能安心地去讀取之前傳給job的native container。呼叫JobHandle.Complete() 會讓main thread等待該jobHandle相依的所有job都執行完畢,code才繼續往下跑,你可以想像這是一個多執行緒任務收束回main thread的sync point。

Burst Compiler

黑魔法,之後再說明,但極香。

先用就對了。

一個傳統,在Main thread執行的程式

首先,為了方便比較結果,我先做了一個簡單的測試用場景,在Script 開始時建立15*15*15個cube。

並在每個frame,讓各個cube根據perline noise跟sin值隨著時間去移動。

這支程式相對於常見的monobehaivour腳本,其實已經有一個最初步的優化 - 將所有cube的Update()集中到一個腳本中,在單一for迴圈進行更新。

關於將邏輯分散到各處Update()的一些問題,可以參考官方Blog 10000 update() calls

接著我們用profiler來看一下這支程式。

除了render跟physics(內建產的cube有自帶box collider,我們可以先忽略),我們在Update()內的logic是場景中花費cpu資源的大宗,右上圖可以看出mainthread中有一條藍色的部分。藍色部分代表Update()中的for迴圈運算花的時間。然後可以看到worker thread都是閒置的,代表這是一支將運算資源都集中在main thread的程式。

第一個Job - 實作IJob

把上面main thread執行的程式,修改為用透過Job來執行,先從最簡單的Job開始,要實作一個最基礎的job,需要以下三個步驟 :

  • 建立一個實作IJob 的struct。
  • 在該struct內定義出需要再thread之間傳遞、類型為native container的成員變數。
  • 實作IJob介面所需要的Execute函式

Execute()裡面就是放我們希望job執行時跑的程式。在這裡我們想將原本Update()內的logic都改成由一個job去跑,所以很簡單就是把Update()內的程式碼直接拉出來丟到Execute()內。

可以看出code基本上沒有太大的變化,差別是我們無法直接操作cube的transform(因為這是reference),所以所有cube的位移都會存到一個NativeArray<Vector3>內,要記得我們是無法在其他thread中呼叫Unity的API的,所以原本的Time.deltaTime, 以及Time.time這些變數的值,都是在mainthread 建立這個job時copy進去的。

因為這個Job會需要2個native array,再程式一開始就先把native array準備好 —

跟原本的差異只是把原本在Update()中計算會時使用的Vector3[]改成NativeArray<Vector3>,為了方便閱讀,程式中的native container變數我都會以m_native作為開頭。

接著在每個Update() 中,建立一個BackgroundNoiseJob ,並將需要的data傳進去,包刮兩個native container,接著呼叫schedule()

schedule() 後別急著呼叫m_jobHandle.Complete()

為了讓job可以在背景跑一段時間,可以等到LateUpdate()時再呼叫m_jobHandle.Complete() ,再程式執行到Complete()下面時,就可以安心地去讀取job計算好的offsets array,並更新所有transformposition

程式執行完切記要對每個native container做Dispose()的動作

將update邏輯放到job內於其他woker thread執行結果

場景上看起來的結果一模一樣,但從profiler可以看出程式的運算壓力,從main thread轉移到某個worker thread上了。第一個Job完成。

實作IJobFor

下一個Interfce — IJobFor顧名思義,就是一個在內部直接用for迴圈去呼叫每一個Execute()的Job,並且把for迴圈的對應index作為參數傳入Execute()內。原本實作的Execute() 也會變成Execute(int index)

將前面的IJob改寫為IJobFor後如下 -

因為IJobFor本身會把for迴圈對應的index傳進來,不需要再Execute()內再另外寫一個for-loop了,只要透過index去存取對應native array的data即可。

schedule()時的參數會比IJob還多一個,需要多給一個integer,這個integer代表了for-loop的長度,比如說我們有N個Cube,給長度N,這樣程式執行時就會呼叫Execute(0)、Execute(1)、Execute(2).....Execute(N-1)

m_jobHandle = noiseJob.Schedule(m_cubes.Length, m_jobHandle);

其他部分的code跟第一個IJob完全一樣。

實做真正的平行化運算

目前的profiler結果

寫到這邊,實作了兩種Job,成功的將原本在main thread的Update() 中厚重的計算移動到了另一個worker thread中,但仔細看profiler的截圖,會發現大部分的worker thread仍然是閒置狀態。

理想上,所有worker thread都能平均的分配到一部份的task,並以最快速度完成所有data的運算。為了充分發揮這些閒置的worker thread,就需要將程式改為真正的平行運算。

而第三種Job - IJobParallelFor,可以幫我們輕鬆地達成這種任務。

IJobParallelFor

首先,把之前的實作的IJobFor的程式改成實作IJobParallelFor這個介面。

然後就完成了 !。事實上IJobForIJobParallelFor並沒有什麼區別,唯一的差異是 — ,ParallelFor的版本會將每一次的Execute(i) 包成一個更小的Job,而這些Job會在Job System Manager分散到不同的worker thread中去排隊/執行。

要更直觀的看分配流程可以看這張圖 —

在背後,Job System將ParallelFor中的每個execute打包成為更小的job,並分送到不同的worker thread中執行

值得注意的時候是在呼叫Schedule()時,相較於之前的IJobFor我們需要多傳入一個參數,Batch Size,batch的大小會影響在拆成更多native job時,native Job的數量以及分散的程度。

注意Schedule時,有三個參數,分別是for-loop的長度, batch size, jobHandle

EX:

  • batch設為1 :
    即每個batch只包含一個Execute()
  • batch設為2 :
    每個batch包含兩個Execute(),比如說第1個batch會有Execute(0)Execute(1) ,第二個batch則有Execute(2)Execute(3) ….依此類推

原則上我們希望job能越分散越好(batch size小),不過根據情況,有些時候太分散反而會影響thread間的效能,所以一般來說是建議先設為1,然後再根據情況慢慢調整。

最後我們來看一下使用IJobParallelFor 的Job後,執行程式時的profiler。

從右上的截圖可以看出,現在有很多worker thread都有分配到task了 ! 而且每一個worker thread都只花了大約0.25ms就執行完各自的job了,main thread呼叫Complete() 後幾乎是沒有等待的時間,徹底解放了main thread的壓力。

效能的比較

最後來比較,對15*15*15 = 3375個cube的nosie + sin運算,不同的實作,
對main thread來說,執行時間分別是多少。

  • 傳統的update內計算( 僅Main thread ) — 1.49ms
  • IJob ( 單一 worker thread ) — 1.51ms
    (等待Job完成佔1.37ms, job完成後mainthread又做了佔0.14ms的計算)
  • IJobFor( 單一worker thread ) — 1.49ms
    (等待Job完成佔1.37ms, job完成後mainthread又做了佔0.12ms的計算)
  • IJobParallelFor(多個worker thread平行運算) — 0.4ms
    (等待Job完成佔0.16ms, job完成後mainthread又做了佔0.24ms的計算)

可以看到,除了拆成多個worker thread的IJobParallelFor 的實作以外,其他Job的實作對CPU來說並沒有顯著的差異,原因是因為 — 即使將所有運算都轉移到一個Job上面,main thread如果需要取得運算後的結果,仍然會需要等待Job完成,而其他worker thread本身也不會跑得比main thread還快,所以只是讓main thread從原本運算1.49ms的部分,變成等待Job 1.37ms 而已。

而IJobParallelFor因為會將運算分散到更多worker thread去平行運算,自然這個task完成的時間更短,也讓main thread等待的時間由1.37ms變成0.16ms了

可以透過Double buffering的方式,來避免main thread一直等待Job完成 —

準備兩份data,一份是Job負責計算的,另一份則是是每次Job完成時複製出的一份cache,而main thread的code只會讀取這份cache,這樣就不需要等待Job計算完結果了。只是main thread獲得的資料都只會是上一次job計算完成時的一個備份罷了。

再進一步優化效能 - Burst Compiler

單單的多執行緒、平行運算,其實都還不足以讓Unity的DOTS稱得上出眾,只要是Job system中的Job,都可以在job的struct最上方補上[BurstCompile] attribute。Burst compiler會將這段這個Job的code編譯為對CPU來說更加優化、SIMD — friendly的native code。

C#相對於C、C++來說,是一個很慢的語言,即使現在有IL2CPP在產版本時根據不同平台產出對應的c++ code,但仍然經過了C#的層層包裝轉出來的code,速度上很難跟原本就是用c++寫的應用程式比擬,但是透過Burst compiler編譯過的code,甚至可以比純用比c++寫還要快。

最所有job上面都補上[BurstCompile]

補上之後在針對所有情況的效能做一次測試:

  • 傳統的update內計算( 僅Main thread ) — 1.50ms
    (沒變是正常的,Update()內計算沒用job,與burst無關)
  • IJob
    1.51ms -> 0.31ms (Job花費1.37ms-> 0.11ms)
  • IJobFor
    1.49ms -> 0.33ms (Job花費1.49ms -> 0.13ms)
  • IJobParallelFor
    0.4ms -> 0.23ms (Job花費0.16ms -> 0.04ms)

對使用者來說,僅僅只是在Job上面補一個[BurstCompile] 而已,計算速度整整快了10倍。簡直是黑魔法。

根據程式碼架構的不同,以及計算時使用更加SIMD friedly的方式去做數學運算。還可以再進一步的提升效能。在Unity中搭配Burst compiler絕對是在CPU端榨出效能的關鍵。

Unity的向量運算是出了名的慢,可以參考playdead工作室針對Inside做效能優化的GDC影片。
另外還有一包取代Math的Mathematics package,內部的運算也是有針對burst compiler做了特別處理,也是可以提升效能

下一步 ?

到這邊已經介紹了IJob, IJobFor, IJobParallelFor三種Job,基本的多執行緒功能像是將某個計算丟到其它worker thread,或者在多個worker thead上一次平行計算N筆資料,都已經可以達成了,但現實是更加複雜的,Job的任務與Job間的相依也會越來越複雜。

在下一篇我會介紹一些其它常用,但是沒有在官方文件內Job Interface,像是forBatch、forDefer、ForFilter。

這些都介紹完之後,就可以開始寫真正的Data oriented的程式碼,配合GPU Instancing的API,更加優化的演算法(Tree),可以做出一支真正稱得上是高效能的程式。

--

--