用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的用法。
雖然這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,並更新所有transform
的position
程式執行完切記要對每個native container做
Dispose()
的動作
場景上看起來的結果一模一樣,但從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完全一樣。
實做真正的平行化運算
寫到這邊,實作了兩種Job,成功的將原本在main thread的Update()
中厚重的計算移動到了另一個worker thread中,但仔細看profiler的截圖,會發現大部分的worker thread仍然是閒置狀態。
理想上,所有worker thread都能平均的分配到一部份的task,並以最快速度完成所有data的運算。為了充分發揮這些閒置的worker thread,就需要將程式改為真正的平行運算。
而第三種Job - IJobParallelFor
,可以幫我們輕鬆地達成這種任務。
IJobParallelFor
首先,把之前的實作的IJobFor
的程式改成實作IJobParallelFor
這個介面。
然後就完成了 !。事實上IJobFor
跟IJobParallelFor
並沒有什麼區別,唯一的差異是 — ,ParallelFor的版本會將每一次的Execute(i)
包成一個更小的Job,而這些Job會在Job System Manager分散到不同的worker thread中去排隊/執行。
要更直觀的看分配流程可以看這張圖 —
值得注意的時候是在呼叫Schedule()
時,相較於之前的IJobFor
我們需要多傳入一個參數,Batch Size
,batch的大小會影響在拆成更多native job時,native Job的數量以及分散的程度。
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++寫還要快。
補上之後在針對所有情況的效能做一次測試:
- 傳統的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),可以做出一支真正稱得上是高效能的程式。