Unity DOTS 学习笔记2 - 面向数据设计的基本概念(上)
上一章,我們安裝了ECS套件,也進行了一些介紹,但是比較籠統。沒有一些基礎知識儲備,很難開始編寫代碼。本章首先翻譯和整理了部分Unity官方的DOTS知識,需要對面向數據有更深刻的認識。
DOD知識準備
要學習DOTS,你不能只是獲取API文檔并深入研究,當然API文檔也很不健全和友好。
你必須了解:
面向數據設計的基本概念
Unity中的面向數據設計
因為篇幅問題,本章介紹面向數據設計的基本概念。
面向數據設計(DOD)與許多開發人員在其整個職業生涯中都在使用的面向對象編程(OOP) 相比是一個巨大的變化。這意味著 DOTS 的學習曲線可能很陡峭,并且有很多陷阱可能會阻止您獲得您希望的性能優勢。所以本學習筆記就是對這部分知識的總結和建議。
第一部分:了解面向數據的設計
1. 了解DOD
面向數據的設計(DOD) 是面向對象編程(OOP)的一種根本不同的方法,許多開發人員將其用作他們的主要(或唯一)編程范式。
面向對象編程(OOP)是將你的代碼結構化成現實世界的事物類型的類。一個類的實例代表一個單一對象。通常數據部分都隱藏在私有變量中,有一些方法可以對數據進行操作。還有繼承的對象來表現相似但是不同的對象,結果是這些單獨的對象分散在整個內存中。OOP對人類來說是直觀的理解,但是CPU執行效率并不高。
相比之下,DOD考慮的是數據,以及最好的在內存中構建數據,以便讓CPU有效的訪問。DOD封裝的不是整個對象,而是將對象分解為組件,再將組件分組為數組,然后遍歷數組進行數據計算和轉換。DOD用例考慮的是組件,而不是對象。要成功的使用DOD,你要忘記封裝、數據隱藏、繼承、多態、引用類型,對你沒幫助。
我們通過一個例子來看看OOP和DOD的區別,這是一個虛構游戲“沙灘球模擬器:特別版”的屏幕截圖。玩家已經激活了一個能量提升來移動所有的綠球。
在OOP中,代碼遍歷檢查每個類的顏色,并設置位置,雖然數組是連續的,但是內存數據量過大,導致CPU的Cache命中率過低。在DOD中,球體只有顏色和位置數據,同樣容量存放的數據更多,這樣就可以加大CPU的Cache命中率,從而加快處理速度。
2. Unity中的DOD
大約2018年Unity發布了Mega-City演示(更多其他DOTS資源),MegaCity大約包含了:
*4.5M Mesh renderers
200K Unique objects per building
100K Individual audio sources
5K Dynamic vehicles
60 FPS
要達到這種高性能,關鍵方面是:
1,緩存友好的內存布局(Cache friendly memory layout)
2,并行化(Parallelization)
3,編譯器優化(Compiler optimization)
新的 Unity-Tech-Stack 包含幾個新庫。它們都是根據這些原則創建的。
- Job-System讓您可以在多個 CPU 上并行工作,這在 Unity 之前是不可能的。
- Burst-Compiler使用LLVM 生成超快速矢量化代碼。
- Entity-Component-System幫助您以緩存有效的方式存儲和訪問您的數據
- Collections-API讓您可以直接訪問非托管內存
- Math library 添加了新的向量類型,如float3,您已經從著色器語言中了解了這些類型,并使 Burst-compiler 能夠向量化您的數學運算
接下來,我們將解釋這3個關鍵方面來達到高性能。
1,緩存友好的內存布局(Cache friendly memory layout)
緩存未命中(Cache Misses)
DOD的設計就是要組織數據進行有效管理,目標是盡可能的命中緩存,以便盡可能快的為CPU提供數據。
CPU的運行速度非常快,以至于RAM和CPU寄存器(Registers)之間帶寬和延遲通常是瓶頸因素,而不是CPU本身。這就是為什么CPU和RAM之間會建立多個緩存的原因。
該圖顯示了一個金字塔,距離CPU寄存器越近內存就越小,訪問速度也就越快。當CPU需要一個值,它首先從L1開始在緩存(Cache)中查找,如果它不在緩存中,就從內存中加載,這非常慢,下表顯示了 Intel Core i7-8700K 的緩存大小。
| L1 Cache(數據 Data) | 192 KB |
| L1 Cache(說明 Instructions) | 192 KB |
| L2 Cache | 1.5 MB |
| L3 Cache | 12 MB |
下表包含英特爾酷睿 i7-4770 的(近似)訪問時間
| 執行典型指令 | 1 |
| L1 | 4 |
| L2 | 12 |
| L3 | 36 |
| 從主存中獲取 | 36 + ~100 納秒 |
正如您所看到的,數據離 CPU 越遠,將數據加載到寄存器中所需的時間就越長。為了避免那些較長的加載時間,要盡可能避免緩存未命中。因此,您需要了解如何訪問緩存。
緩存行(Cache Lines)
今天的 CPU 不會逐字節訪問內存。相反,它們以(通常)64 字節的塊(稱為高速緩存行)獲取內存。例如,如果您遍歷一個整數數組,則會同時加載 8 個整數值(64 字節緩存行大小/每個整數 4 字節 = 8)。這可以防止每次讀取值時緩存未命中。此外,高速緩存也足夠智能,可以根據指令預取所需的前一個或下一個高速緩存行。因此,您的訪問模式越可預測,性能就會越好。
結構(Struct)與類(Class)的數據布局
結構體數組或者原始類型數組(例如int[]),因為結構大小在編譯時是已知的,所以可以連續打包到內存,這不適用于類的數組,由于類的多態性,每個元素可以有不同大小,所以無法連續打包。只有指針指向隨機位置,具體取決于什么時候new的,而不是創建數組的時間。
我們編寫一個例子測試:
using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using UnityEngine;public struct StructA {public float f; } public class ClassA {public float f; } //UTF8 說明 public class CTest : MonoBehaviour {int allnum = 10000000;int[] nolist;int[] nolistRandom;// Start is called before the first frame updatevoid Start(){nolist = new int[allnum];for (int i = 0; i < allnum; i++){nolist[i] = i;}nolistRandom = new int[allnum];for (int i = 0; i < allnum; i++){nolistRandom[i] = i;}}// Update is called once per frameprivate void OnEnable(){test();}void test(){//首先模擬內存分布數據StructA[] alist = new StructA[allnum];for (int i = 0; i < allnum; i++){alist[i].f = i;}ClassA[] blist = new ClassA[allnum];for (int i = 0; i < allnum; i++){blist[i] = new ClassA();blist[i].f = i;}ClassA[] clist = new ClassA[allnum];for (int i = 0; i < allnum; i++){clist[i] = new ClassA();clist[i].f = i;}clist = clist.OrderBy(x => Random.value).ToArray();var stopwatch = new Stopwatch();System.GC.Collect();stopwatch.Reset();stopwatch.Start();for (int i = 0; i < allnum; i++){alist[i].f += Random.value;}stopwatch.Stop();UnityEngine.Debug.Log("struct : " + (stopwatch.ElapsedTicks / 1000).ToString("F2"));System.GC.Collect();stopwatch.Reset();stopwatch.Start();for (int i = 0; i < allnum; i++){blist[i].f += Random.value;}stopwatch.Stop();UnityEngine.Debug.Log("Class A : " + (stopwatch.ElapsedTicks / 1000).ToString("F2"));System.GC.Collect();stopwatch.Reset();stopwatch.Start();for (int i = 0; i < allnum; i++){clist[i].f += Random.value;}stopwatch.Stop();UnityEngine.Debug.Log("Class B : " + (stopwatch.ElapsedTicks / 1000).ToString("F2"));} }
測試中每個類型對象數組創建了1000萬個對象,數組每組大小40M。我們先創建好,然后用相同的賦值操作進行賦值進行比對。
struct是按順序的,ClassA也是順序的,ClassB是打亂的。
結果中看到struct是最快的,classA稍微多一點,classB就多了5倍左右。這個測試中,我們迭代了每個元素按照順序的完整數據。
請記住:如果您測試緩存未命中,許多外部環境(如操作系統、線程和其他進程)會偽造您的測試結果,因為它們也使用緩存。
選擇性數據訪問
在許多用例中,您不需要訪問整個數據,而只需要訪問其中的一部分。常見的用例是:
- 你有很大的游戲世界,你只想處理當前可見的實體
- 您只想在整個網格的一部分上進行操作
- 您只想處理用戶選擇的實體
- 您將數據切成塊,只想訪問一個
正如我們在上面的測試中看到的,按順序排列數據以避免隨機訪問非常重要。在某些用例中,這是不可能的。下一個示例將測試不同的訪問模式如何影響緩存未命中。
int[] steps = new[] {1, 2, 4, 8, 16, 32, 64, 128, 256} for (int k = 0; k < steps.Length; k++) {int stepSize = steps[k];int[] arr = new int[32* 1024 * 1024];for (int i = 0; i < arr.Length; i += stepSize ){arr[i] *= 3;} } // This code is a little bit simplified. With bigger step sizes, // less samples on the array are done, but you can find the // full source of all experiments in the Appendix.
正如您所看到的,步長越大,即使完成相同數量的計算,運行時間就越長。進一步增加步長意味著完全隨機訪問您的內存。有趣的是步長 16 和 32 之間的圖形跳轉。這里超出了緩存行大小(16 * 4 字節 = 64 字節)。步長為 32 時,每次數據訪問相當于緩存未命中,運行時間幾乎翻了一番,從 1503 到 2739。
這也是如果你創建一個二維矩陣,它的行主要遍歷將比它的列主要遍歷更快的原因。一行存儲在連續的內存位置中,因此在高速緩存行中被提取。
面向對象與面向數據的數據布局
這個示例中,展示了與面向數據相比,數據如何以面向對象的方式存儲。
// The struct defines a sphere in a object oriented way public struct ObjectOrientedSphere {Vector3 position;Color color;double radius; }; ObjectOrientedSphere[] objectOrientedSpheres;// The class defines several spheres in a data oriented way. The data is tightly packed into arrays public class DataOrientedSphere {Vector3[] position;Color[] color;double[] radius; };// Assume you have a list of ObjectOrientedSpheres that you want to move // every frame by 1 public void MoveObjectOriented(ObjectOrientedSphere[] spheres) {for (int i=0; i<spheres.Length; i++){spheres[i].position += 1;} } // This code does the same for the DataOrientedSphere public void MoveDataOriented(DataOrientedSphere spheres) { Point[] positions = spheres.position;for (int i=0; i<positions.Length; i++){positions[i] += 1;} }該示例以兩種不同的方式定義球體。ObjectOrientedSphere 的定義與您對日常程序員生活的期望一樣。結構或類包含對象工作所需的所有數據。DataOrientedSphere 以可以更有效地訪問數據的方式定義數據,只需創建一個對象并存儲每個值的數據,而不是為每個對象存儲數據。這里重要的一點是,如果知道需要訪問不帶顏色和半徑的位置數據,則應該將它們彼此分開。
上面的代碼中,一個球體大小是24字節,下面的測試就是更改球體大小(通過增加其他屬性),那么按順序進行Move移動球,會發生什么呢?
例如64字節大小的球是:
該圖表顯示,當您更改面向數據的球體的大小時(遍歷的數組長度沒有變化),性能保持不變。原因很明顯,因為其他數據(顏色和半徑)存儲在完全獨立的內存區域的其他數組中。相比之下,面向對象的領域變得越來越慢。在遍歷數組時,會產生越來越多的緩存未命中。有趣的是,當您超過 64 字節的高速緩存行大小時,再次看到強烈的性能損失。
| 64 | 109 | 78 | 1,4 |
| 128 | 197 | 78 | 2,53 |
訪問大小為 128 字節的對象幾乎是訪問大小為 64 字節的對象的兩倍。面向對象的運行時間沒有比現在差的原因是 CPU 非常擅長預測您接下來可能需要哪些數據。
如果對象變得越來越大,以面向對象的方式存儲數據可以將性能降低多達 6 倍。
在前面的示例中,使用了 32 MB 的數組大小。讓我們看看不同的數組大小是否會影響結果。
在垂直軸上標記了數組大小。該測試針對面向數據的球體(標記為 DO 4)和不同大小的面向對象的球體(標記為 4 – 512)運行。兩個軸都是對數的。如您所見,所有數組大小和類大小的性能都保持線性。
緩存失效
當數據進入兩個不同的緩存位置時會發生什么。例如,變量 float a 可能在CPU核心 1 和核心 2 的 L1 緩存中。當您更新該變量時會發生什么?
這種情況被稱為數據競爭。當多個線程同時訪問內存中的一個位置并且至少有一個線程打算改變該值時,就會發生這種情況。對我們來說幸運的是,CPU 可以解決這個問題。每當寫入指向緩存中的內存位置時,內核對該內存位置的所有緩存引用都將失效。其他內核必須再次從主存儲器加載該數據。但是,由于數據總是加載到緩存行中,因此整個緩存行無效,而不僅僅是更改的值。
這給多線程系統帶來了新的問題。當兩個或多個線程嘗試同時修改屬于同一緩存行的字節時,大部分時間都浪費在使緩存無效并再次從主內存中讀取更新的字節上。這種效應稱為虛假共享。
與關系數據庫的關聯
面向數據設計背后的思想與您對關系數據庫的看法非常相似。優化關系數據庫還可以更有效地使用緩存,盡管在這種情況下我們處理的不是 CPU 緩存而是內存頁面。一個好的數據庫設計人員也可能會將不經常訪問的數據拆分到一個單獨的表中,而不是創建一個包含大量列的表,因為只有少數列被使用過。
結論
對主存儲器的隨機訪問比順序訪問慢大約 6 倍。
今天到這里了,下一章分享Unity中的面向數據設計。
引用:
面向數據的設計
Unity`s “Performance by Default” under the hood
DOTS Best Practices
總結
以上是生活随笔為你收集整理的Unity DOTS 学习笔记2 - 面向数据设计的基本概念(上)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 初识托福TOEFL口语
- 下一篇: 屏蔽博客园背景动态线条