前往
大廳
主題

聊聊VRchat的CPU開銷問題(未完成)_20240106

垂暮龍-青月(動物朋友 | 2023-10-18 21:49:20 | 巴幣 10 | 人氣 116

最近對於該問題越來越不滿而盡可能詳細的分析,得到許多問題的解答。

大致上VRchat分成avatar和world兩大組成的部份。

使用zen3(5600X),記憶體預設CL16 3200Mhz。

avatar部份有:
物理骨骼(PB)、動畫、約束、繪製四部份。

world部份有:
Udon#、動畫、約束、繪製四部份。

不測量一些較少用的部份或可以被歸納進的(粒子系統歸納進繪製,而cloth由於物理模擬部份開銷過大且幾乎不會大量使用(性能過於差勁))而忽略。

然後在數據上分別測量了L2以內命中率和L3 miss和分支預測三大項,另外包含記憶體頻寬與延遲週期。

PB
L2以內命中率95%,L3 miss約12~45%,分支預測率準確度98.2%,記憶體頻寬10GB/s,延遲週期約375~520cycle。
動畫
L2以內命中率98%,L3 miss約17~57%,分支預測準確度99%,記憶體頻寬20GB/s,延遲週期375~690cycle。
約束
L2以內命中率98%,L3miss約10%~44%(浮動非常大測試不準),分支預測準確度99.8%,記憶體頻寬2GB/s,延遲週期375~380cycle。
draw call(包含其他)
L2以內命中率90%,L3miss 13~35%,分支預測準確度99.5%,記憶體頻寬8GB/s,延遲週期375~400cycle。
udon(無法測量或準確測量)
L2以內命中率88~94%(?),L3 miss 16~33%,分支預測準確度~99.2%(?),記憶體頻寬3~5GB/s~(?),延遲週期375cycle。

上述L3的範圍是因為使用的負載量程度不同,基本上動畫極度誇張才能到57%這個數值,正常情況下和PB都是30~33%,這個正常範圍相當於一個avatar身上動畫層加總約60層左右平均和128物理骨骼組件和1024物理骨骼變換至32個avatar。約束方面如無必要活動請盡量關閉,影響可能大概從10~20%降低至1~2%左右。

drawcall常態約20~30%,udon無法準確測量不具備太多意義,最終在混合使用下L3 miss本身開銷不大約20%以內,擁有自身較高開銷和數個avatar約30~40%的L3 miss,高udon開銷和16至32個avatar會到達50%或超過50%的L3 miss。

分支預測方面約平均98~98.5%僅供參考,記憶體頻寬使用約10GB/s,L2方面不進行準確測量。

有(?)代表沒辦法準確測量,測量相當有限。

更高的記憶體存取延遲週期來自於TLB miss的影響,正常遊戲過程中平均約375~420cycle來回。

準確測量重要的部份後獲得該方面D-TLB L1約在0.2%左右,根據其影響週期計算並非問題原因,原因在於DDR4密集存取的供應頻寬能力不足,雖然總頻寬使用量並未很大,但到達一定程度的使用頻寬仍會造成延遲較顯著上升。

以avatar在無udon下在不可見物件的狀態下約有97%的開銷來自於動畫、約束、PB三個部份,即使可見的繪製開銷也才通常佔據5~10%,很難整體到達20%的開銷。

udon擁有著與drawcall相似的特徵,源自於不少開銷來自於實作udon的UI,然而實際上L2以內的命中率實際上不知道是否有問題,而相當依賴L3的容量。

udon本身造成在指令上約比普遍上面這幾項共同擁有的ICF miss率30%、微指令快取miss率30%,指令獲取率(朝IC獲取)6%,指令快取miss 2%和資料獲取佔比40%更高。
為ICFmiss率50%(指令快取獲取)、微指令快取miss率45-50%,指令獲取率(朝IC獲取)50%,指令快取miss 8%和資料獲取佔比50%。
這是源自於udon#的VM沒有任何JIT以及其擁有大量檢查所造成的,但是他擁有著一種更奇特的問題存在,那就是資料局域性非常差勁,目前還不知道如何找出問題原因。

可能懷疑是udon使用的Unity3D實作,也可能是自身的問題,也可能是使用者的問題,因為udon產生出的問題就是miss率與容量呈現反比趨勢,其因子為1。

如果說刻意使L3大量miss,其他功能可能因此慢約10~20%,那麼udon將可能顯著造成額外的開銷。產生了一定程度上可以觀察和測量到的avatar開銷放大的效應。

avatar會上的動畫、PB、約束等造成大量資料佔據在LLC(L3)上,這導致L3 miss顯著增加。

一個1ms的avatar到不同世界可能造成1.3ms~2ms甚至更高的CPU frametime開銷,只是大多數約1.3ms左右。

對於約束本身來說不應該造成過多L3 miss,然而對於相等的CPU frametime來說,最終都會跟動畫與PB等同樣造成相等的L3 miss影響,估計是Unity3D實作的問題,導致拉高L3不必要的占用,促使真正需要的Udon受到影響。

動畫和PB與約束和drawcall等之類都會一定程度累加但不是直接加起來的程度,導致L3 miss在實際遊戲場景可能突破50%的L3 miss,雖然對於drawcall以外可能不是影響很大,但是對於udon這個異常影響很大的項目來說,可以說相當於直接放大多倍。

udon在world上可能佔據1ms~3ms的負載,然而一些情況下部份重負載的世界高達7ms甚至10ms,極端甚至有高達12-15ms這類的,這類幾乎與L3的容量性能成比例。

在測試上若同等頻率的zen3,測量可以在世界本身跑到100fps(32MB L3),如果你使用的版本僅僅只有16MB,那在這些世界上單純世界本身就只剩下50fps。
也有人嘗試做些benchmark,最終測量這些世界可以高達300fps。

這是相當異常的問題,導致了X3D系列的CPU顯著提升,並導致在VRchat普遍遊戲過程中的IPC提升超過30%以上,即使頻率較低最終也導致了20%的fps提升,當初誤以為是drawcall之類造成的問題,但經過反覆測量重現確認不是。

因為zen4在該方面改進造成IPC提升超過10%甚至20%的測量程度,即使是L3 cache不多的12th也可能因L2等因素IPC提升高達10%(相對zen3)。

udon在對比沒有C#編譯最佳化的情況下慢20~100多倍,有正常開啟最佳化差距甚至200~1000倍的差距。

慢的不僅僅是指令、分支這些直譯器常見所造成的性能問題,而是在於絕對不正常的資料快取的依賴程度,這代表在實作上有著相當大的問題。

一般正常python、ruby不涉及什麼大量資料處理時,實際上相當依賴頻率或IPC改進而不是L3的容量大小,這促使X3D甚至比一般版本慢或最多差不多。

正常來說應用程式的容量反應miss率的因子低於0.3,即使較依賴也大概在0.3~0.5左右。而大量依賴如EDA或物理模擬這類他們的因子大致上在0.7多左右,不可能到1這種完全沒有資料局域性的情況一樣,而且其容量影響程度甚至比因子還要來得巨大,這是絕對反常的訊息。

算式:
目標miss率=當前基準miss率*容量^(-因子)

例如5%miss*3^(-0.5)=2.88%miss率。

雖然動畫等記憶體頻寬開銷沒有到很高,但考慮實際使用不可能是很好分散於單位時間內,即使是20GB/s也建議最好是雙通道DDR4 3200以上,會好上一些只是影響更大部份來自於延遲降低,且頻寬較多也會有利於降低不必要的CPU使用率。

實際上遊戲過程中可能被各類負載均勻分散,即使是使用到4core以上,L3 miss高達40-50%,最終也只能測量約使用掉10GB/s的記憶體頻寬,但仍然建議至少要雙通道。

基於密集存取頻寬的特性,為降低一些密集存取所造成的延遲,請至少使用雙通道解決該問題避免大量上升,使得保持總頻寬在至少一半以下以利頻寬延遲增長不超過20%以上,如果要大量使用頻寬請使用DDR5。

___

Udon常用如迴圈或單純的計算並不因為Cache而影響性能,根據其運算的內容相當於沒有開啟最佳化的C#約慢1500~1萬倍左右,若以空迴圈來看目前測試約慢了原本的C#一萬多倍左右。

約C#本身函數耗時,以簡單較基礎慢約200~1000倍之間,而昂貴的獲取組件GetComponent ,僅僅為C#原本耗時的兩倍。

在遊戲中並非執行mono-2.0的dll而是IL2CPP,例如迴圈和計算等基礎的運算操作會更慢一些,例如編輯器中可以跑到50fps左右的速度僅剩下41~42fps。

而函數或GetComponent 反而從編輯器中的20~22fps提升到27至28乃至30fps左右,這是個不小的差異。

GetComponent在Udon中約相當於因子0.5左右的影響,miss率影響並不低,最終兩倍的Cache能影響超過17-20%的fps,其餘函數則更低一些,而在Unity3D本身中影響比例更大(這是因為UdonVM的成本均攤了影響)。

但是有一些地方則非常昂貴,例如基礎的new String(),這就不得不說C#或C++這類語言是怎麼設計的。

在函數中我們宣告的變數是局域變數,而非全局變數,它有一個特性就是一但函數執行完了就會被銷毀,其資料結構基於堆疊(stack)所設計,而不利於大規模擴展其範圍,但執行簡單而高效。

一般而言如果我們宣告一個基於堆疊的變數陣列非常巨大,例如你宣告了高達五十萬個int相當於2MB就可能堆疊溢出而崩潰。

那麼我們必然得設計一個能容納許多動態產生出來的變數的資料結構,那就是堆積(heap),但是堆積雖然能容納很多內容了,卻會逐漸隨著規模變大而緩慢就是了。

一般而言我們在C/C++中可以明確知道我們操作的是基於地址或稱作參考的變數,還是基於值內容的。

例如*int內容並不是int,而是指向int內容的地址,而**int則是指向*int的地址。
只有單純的int才是內容就是int的內容,比方說int=3,你對它取值獲取的就是3而不是一長串的地址。

而在C#和Java一類幾乎所有隱藏起來的細節的語言,幾乎都是參考或操作地址,除非你是基礎的int、float、char這類固定長度並不擴展長度且簡短的內容。

這意味著我們宣告任何類別例如,Room room其實是Room *room,所以指向的地址在宣告時是沒有的,必須得new Room();

然而有new就有Delete或Destructor,然而我們寫一些函數並回傳Class的實例時,總是在這些語言上忽視,畢竟都不知道傳遞到哪裡去了,然後什麼時候才需要判斷已經不需要用了才執行Delete,這些無疑造成了心智負擔,畢竟你不Delete記憶體會逐漸增長,但錯誤操作的結果會立刻出錯擲出例外而崩潰,亦或是錯到不能再錯。

於是乎GC(garbage collection)也就出來了,你負責new和邏輯的過程,至於用完了誰清理就交給GC來做。

一般而言目前現代化的GC都已經考慮了各種硬體和軟體上的細節實作了高吞吐和延遲權衡設計,比如C# .Net操作這類Array、String之類都是相當輕而易舉的,但是有一個例外就是Unity3D的GC。

由於Unity依然還在用mono並且還是基於老舊mono修改而來,使用的是Boehm GC,也就是貝姆GC,雖然在之後改為增量式垃圾收集,但仍保持著原有簡單且保守的GC設計,不分代也不壓縮,而分代與壓縮可以說是現代GC必然擁有的特徵。

這造成了哪些問題?在小規模下貝姆GC相對簡單快速,但是規模上去時由於缺乏分代設計,所有在堆積上活動的物件都會對GC的活動和操作造成極大的負擔,也就是基於Unity C#所管理的所有物件,一但需要解構或一些手動操作,數額龐大的情況下單位操作是昂貴的。

在Unity 2021後內建一個通用物件池設計,但是依然擺脫不了GC依然在GC的堆積上,除非利用ECS的原生陣列才能真正擺脫並手動管理記憶體。

只要擺脫不了設計,物件池中存活著非常大量的物件,依然潛在給GC造成極大負荷,一旦需要執行GC的操作就會很昂貴。

另一方面缺乏壓縮的設計,雖然壓縮會消耗不少CPU時間,但是卻有效減少了記憶體碎片的存在,關於記憶體碎片請自行查詢記憶體的分頁設計。

一定程度上使得GC配套的記憶體分配器,更為高效,規避了在Cache和Memory上存取和分配的低效率。

缺乏這兩點在一些人測試下,Unity的GC性能非常誇張的差勁,比.Net持續改進幾乎每版更好的情況下慢到堪稱千倍的差距,尤其是很多被封裝的較高階易用函數內部都存在著分配記憶體並被GC的成本。

尤其是對比的是.Net 6,要知道去年底差不多就.Net 8出來了...這差距是越拉越大。

而Unity在C#上的GC幾乎沒有什麼改進,這迫使所有開發者如果要高性能就必須考慮一些方案替代簡單的操作,並且可能初期就得考慮,而非等規模上去後才修改一連串的依賴。

而大多數人僅僅只是創建物件並執行一些簡單的邏輯而非龐大的計算,這使得在profile甚至可以誇張到比實際做事如物理碰撞和檢測的開銷還高四五倍以上。

所以在Unity上少用實例化和解構這兩個,而多用GameObject[]或ObjectPool適合理的做法,只要你有預期或想到就可以不用考慮太多就使用了。

只是使用上相對更為不方便就是,而且很多時候會用String用作Tag等標示作用,勢必也得new,甚至拼接字串都會導致重新回收再分配新的String,所以字串可以說是比int和float昂貴的多的操作,因為不是單純的計算而是記憶體操作。

而在VRchat中如果你想通過network類進行網路同步,也切記不要使用任何實例化和解構,因為根本就不可能實現有效可靠且低成本的網路同步。

在VRchat中,自動連續變數被限制單次序列化為200byte,手動序列化則最高到49KB,而每個世界實例中僅只有11KB/s以下的Udon網路同步能力,也就是相當於低於11KB*5=55KB以下(5秒以上超時)。

超出該限制Udon VM仍然繼續執行,但所有關於network的同步和修改都會被停止失效,跟壞了沒兩樣。

如果你的世界擁有非常大量的物件,也請關掉物件所有權轉移之類的選項,盡可能節省有限的同步頻寬,除非有必要才使用(Script掛上預設是開的)。

被實例化的物件可以透過一些script的邏輯從第三方如Player之類上達成同步,但是創建和銷毀之類操作是無法跟著執行的,只能設計為基於同步變數與序列化去觸發同步內容的結果,這意味著需要事先創立好物件並關閉,需要時再來開啟。

所以必須要事先預估一個上限,這是物件池設計上的問題,而實例化和銷毀則靈活得多但存在不少開銷與難以同步甚至無法同步的結果。

創作回應

更多創作