Riot工程師:三步讓你的遊戲更新更快更小

LOL1

【Gamelook專稿,轉載請註明出處】

Gamelook報道/對於一個在線遊戲的開發商來說,你總是需要不斷增加新的內容保持玩傢的新鮮感和黏性,但隨之而來的問題是,新內容的出現和老版本之間往往會造成沖突,經年累月的代碼積累很容易出現遊戲延遲甚至卡頓,遊戲運行時間越長,這種問題解決起來越棘手。最近,Riot工程師Tony Albrecht在其開發者博客講述瞭全球最流行的MOBA遊戲《英雄聯盟》新內容增加之後出現的問題是如何解決的,他在博客中提出瞭三步讓遊戲更新更快、占內存更小的方法,以下是Gamelook編譯的博客內容:

做個像《英雄聯盟》一樣需要持續更新的遊戲常常會讓開發者們經常面對非常頭疼的編碼問題,日益增多的遊戲內容和有限的機器配置總會遇到沖突。新內容的增加通常會帶來隱藏成本,除瞭新內容植入成本外,還包括由更多紋理、模擬以及處理所導致的內存和性能成本增加,如果我們忽視(或者說錯估)這個成本,那麼遊戲性能就會下降,《英雄聯盟》的樂趣就會減少,玩傢黏性就會降低,遊戲延遲就會增加,掉幀的現象是讓人很沮喪的。

我是專門負責提高《英雄聯盟》遊戲性能團隊的一員,我們負責客戶端和服務器方面的優化,尋找問題(性能以及其他方面)然後解決它們。我們還會把自己得到的問題反饋給其他團隊,並且為他們提供工具和數據來幫助他們在尚未影響玩傢體驗之前確定性能問題。我們不斷的提高LOL的性能,好讓美術師和設計師們為遊戲增加更多更酷的東西,簡而言之:在他們把 遊戲變得更大更好的同時,我們讓遊戲變得更快。

這是我們團隊在優化《英雄聯盟》經驗系列的第一篇技術博客,隨著新內容以及修復問題數量的增加,優化工作變得非常困難,但這同時也是非常具有價值的挑戰。這篇文章會講述一些我們在做粒子系統(Particle System)所遇到的一些有趣的挑戰,從下面的動畫你們可以看出,粒子系統在遊戲中起到瞭非常重要的作用。

04

優化工作並不隻是重寫一大堆的代碼,雖然有時候的確是這樣。我們所做的改變不僅提高瞭遊戲性能還保持瞭正確性,如果有可能的情況下還會提高代碼的可讀性,最後一點很重要,任何不可讀而且不可維護的代碼都會給未來的遊戲更新帶來技術困難。

在優化現有代碼庫的時候我們采用瞭3個基本的步驟,分別是:確定問題、理解問題和優化解決。

第一步是確定問題:在任何優化開始之前,我們首先要確定代碼是否需要優化,如果對於整體性能的影響不大,那麼優化大量的代碼是沒有多大好處的,特別是這個時間用在其他方面會更好的情況下,我們使用代碼工具和樣本分析來幫助確認不影響性能的代碼。

第二步是理解問題:一旦我們知道瞭代碼庫的哪個部分影響速度,我們會更深層的審查這些代碼,以瞭解問題所在,我們需要知道這些代碼是做什麼用的,還要知道當初為什麼要寫這些代碼,然後就可以確定為什麼這些代碼是導致瓶頸出現的原因。

第三步是優化解決:當我們瞭解瞭代碼為什麼很慢以及它的用途之後,我們就有瞭足夠的信息去分析和得出可行性方案,通過確定問題步驟所用的工具和分析數據,我們對比新舊代碼之間的性能差異,如果解決方案可以讓遊戲變快,我們就進行完整的測試,以確保新代碼沒有帶來新的bug,一旦確定沒有問題,我們就開始進行內部測試。如果新代碼依然不夠快,我們就一直調整直到滿意為止。

下面,我們來看看《英雄聯盟》代碼庫的處理過程並且一步步的講述最近我們對粒子系統所做的一次優化。

第一步:確認問題

Riot工程師們用一系列的分析工具檢測遊戲客戶端和服務器的性能,我們首先使用內部工具Waffles看客戶端的幀率以及高級分析信息(通過工具的特定函數獲得的輸出信息),這個工具可以讓我們內部的客戶端和服務器版本相通,其實,Waffles還可以用於其他事,比如測試中觸發debug活動,檢測像導航網格之類的遊戲內數據並暫停或者減緩遊戲玩法等等。

1

Riot公司的內部工具Waffles

Waffles可以提供一個實時顯示的界面,以及詳細的性能信息,下圖展示瞭客戶端性能表現的經典例子,上邊的圖片(綠色)代表著以毫秒為單位的幀率周期,數值越高,幀速就越慢,非常慢的幀速是可以直接在遊戲中看得到的,也就是遊戲出瞭故障。圖片的下部是重要函數的分層視圖,通過點擊任意的綠色條,工程師們都可以收到最新的詳細幀數信息,通過這個信息,我們可以很好的瞭解哪部分代碼是導致問題的關鍵。

我們使用簡單的宏(macro)在代碼庫內自動檢測重要函數,以提供性能相關的信息。在公用遊戲版本中這個宏是被編譯出去瞭的,但在測試版本中包含瞭一個很小的class,它可以創造一個指令(event)放到一個大的概要文件緩沖區(profile buffer)。這個指令包含一個字符串識別碼(string identifier)、一個線程ID(thread ID)、一個timestamp和其他必須的信息,比如它還可以存儲所有時間發生的內存配置數。當這個指令超出范圍之後,destructor會在這個緩沖區更新指令自construction之後的運行時間。隨後,這個概要文件緩沖區可以被輸出和剖析,理想情況下放到另一個程序中以減少對遊戲本身的影響。

2

Chrome Tracing

在我們的案例中,我們把這個概要文件緩沖區輸出到一個文件上,並且把它轉化到可視化工具中,這個工具內置於Chrome瀏覽器中(你可以在Chrome瀏覽器中輸入‘chrome://tracing/’進行嘗試,它主要用於網頁解析,但輸入解析數據的格式是json,可以從你的數據中很容易構建)。從這裡我們可以看出哪些函數是緩慢的,或者哪裡有大量的小函數在序列中,這些情況都可能是次優代碼(suboptimal code)的征兆。

這裡我來展示如何去做:上面的視圖是典型的Chrome Tracing視圖,展示瞭客戶端上兩個運行的線程,上面的一個是主線程,負責大多數的處理工作,地步的是粒子線程(particle thread),每一個有顏色的條都代表對應的函數,條的長度代表的是執行時間。被調用的函數由垂直疊加區展示,母函數在子函數之上。它非常神奇而可視化的展示瞭執行復雜度以及幀簽名時間(time signature),當我們發現一個帶有次優代碼的區域時,我們可以把粒子截面(particle section)放大,這樣可以看到更多的細節。

6

Chrome Tracing放大之後

我們放大圖表的中間部分,從上面的線我們可以看到很長的等待時間,直到粒子Simulate函數在底線完成的時候才終結。Simulate包含大量不同的函數指令(帶顏色的條),每個都是粒子系統更新函數(particle system update function),該系統的每個粒子的位置、方向以及其他可視化數據都會被更新。一個最明顯的優化就是把Simulate函數多線程化,讓其在主線程和粒子線程上都可以運行,但在這個案例中,我們隻看Simulate代碼本身的優化。

既然我們知道希望在哪裡尋找性能問題,那麼就可以直接轉向樣本分析。這種分析會階段性的讀取和存儲程序計數器,有選擇性的讀取和存儲運行過程的堆疊。一段時間之後,這個信息可以給出一個隨機概述(stochastic overview),告訴我們代碼庫中的時間使用。較慢的函數會出現更多的樣本,更有用的是,用時最長的單個指令可以指出更多的樣本。這樣,我們不僅可以看出哪些函數是最慢的,還可以知道那幾行的代碼是最慢的。如今有很多不錯的樣本分析工具,從免費版的Very Sleepy到功能齊全的商業版本英特爾VTune等等。

通過在遊戲客戶端運行VTune並檢查粒子線程,我們可以看出如下最慢的函數列表

7

上面的這個表格展示瞭一系列的粒子函數。供大傢參考的是,頂部的2個是比較大的函數,每個都更新大量的數據和狀態,在這個案例中,我將主要關註entries 3和9處AnimatedVariableWithRandomFactor<>當中的Evaluate函數,因為它非常小(所以比較容易理解)但解決起來代價又非常高。

第二步:理解問題

既然我們選擇瞭需要優化的函數,那麼我們就要理解它是做什麼的,為什麼要優化。在這個案例中,AnimatedVariables被《英雄聯盟》美術師們用來定義粒子特征是如何隨著時間變化的。在一名美術師為特殊粒子確定的關鍵幀值(key-frame value)之後,代碼就會基於這個數據插值,產生一個曲線,插值方式通常是線性插值或者一級/二級集成(Integration)。動畫曲線(Animation curves)被廣泛使用,單單召喚師峽谷中有接近4萬個,Evaluate()函數在每次遊戲中都被調用數百萬次,另外,《英雄聯盟》中的粒子對於玩法是非常重要的,因此它們的特性必須是不能改變的。

這個class已經用查詢表(lookup table)優化過瞭,它是一個為每個timestep都準備瞭預先計算值的數列,所以計算過程可以被減少到隻讀一個值就可以瞭。這是一個非常敏感的選擇,因為曲線的一級和二級集成代價很昂貴,為每個系統裡的每個例子上的動畫變量進行這個操作會導致處理過程大大減緩。

8b1

當我們看一個性能問題的時候,通過找到最嚴重的案例把問題誇大化通常是有用的,為瞭模擬粒子減速,我開瞭一局單機遊戲,加入瞭9個中等水平的電腦,並且在下路發起瞭大規模團戰。然後戰鬥的時候我在客戶端運行瞭VTune並且記錄瞭大量的解析數據,所以就得出瞭Evaluate代碼形式的樣本歸因(sample attribution)如下:

我現在來說第90行提到的Evaluate()函數中的內容,也就是91-95行代碼,這樣可以更好的展示所說的情形。

8b2

對於不熟悉VTune的人來說,其實這個試圖展示的就是解析期間所收集樣本的代碼,右邊的紅色條指示的點擊次數,條越長就意味著點擊次數越多,所以這行代碼就越慢挨著該條的時間是處理這行代碼所用的預估時間,你還可以把它用到特殊的函數中分析減速的原因。

如果大紅條需要分析的話,第95行代碼就是問題所在。但它所做的就隻是從拼寫失誤的查詢表中復制瞭一個Vector3f,所以,為什麼它是這個函數中最慢的部分呢?為什麼拷貝12個字節如此之慢呢?

答案是因為現代CPU處理內存的方式,CPU都非常忠實的遵循瞭摩爾定律,每年都會提速60%,而內存速度每年的增速隻有可憐的10%。

9

電腦架構:量化方式增長

緩存可以減小性能差別,運行《英雄聯盟》的大多數CPU都有3級緩存,一級緩存最快但也最小,三級緩存最大也最慢,從一級緩存讀取數據隻需要4個周期,而讀取主存儲器卻需要大約300個周期甚至更多,你可以在300個周期內做大量的處理工作。

最初的查詢式解決方案的問題是,按照順序讀取查詢表中的值速度很快(由於硬件預讀),然而我們要處理的粒子並不是按時間順序存儲的,所以查詢起來是隨機順序的,這通常會在CPU等待從主存儲設備讀取數據時導致延遲,雖然300個周期比一級或者二級集成的代價更低,但我們還需要知道的是,這個函數在遊戲中的使用頻率非常的高,所以仍然會帶來大量的延遲問題。

為瞭找到問題所在,我們快速增加瞭一些代碼,用於核對AnimatedVariables的數量和類型(接近3.8萬個AnimatedVariables):

其中3.75萬個是線性插值,100個是一級,400個是二級;

其中3.15萬個隻有一個關鍵值(key),2500個擁有3個關鍵值,1500個包含2或者4個關鍵值。所以比較常見的路徑是單個關鍵值的,由於代碼總是生成一個查詢表,這就產生瞭一個不需要傳播的單數值表,這就意味著每次查詢(返回同樣數值)都會產生緩存丟失,進而導致大量的內存和CPU周期浪費。

通常情況下,代碼成為瓶頸問題的原因有以下1-4個:

被調用次數太頻繁;選擇瞭比較差的算法(algorithm,比如O(n^2) vs O(n));做瞭不必要的工作或者頻繁做必要工作;數據不好:數據太多或者分佈於訪問模式太差。

這個問題和代碼設計不足或者使用都沒有關系,解決方案也很好。但是被美術師們大量的使用之後,普通路徑是針對單個數值的,有些問題在使用過程中是很不明顯的。

順便說一句,作為一個程序員我學到最重要的事就是尊重你所使用的代碼,代碼雖然看起來寫的比較瘋狂,但很可能是出於非常好的原因。在完全理解代碼的用途和設計意圖之前不要撇開現有代碼。

10
通俗版解釋

第三步:優化解決

現在,我們知道瞭問題代碼所在,代碼的用途以及它速度慢的原因,是時候做出一個解決方案瞭。由於每個常見的執行路徑都是為單獨變量設計,我們需要在考慮這種情況的基礎上進行重新設計。我們還知道少數key的線性插值會更快,所以我們也需要把這種情況考慮進去。最後我們可以回到前面integrated曲線中的預計算查找(precomputed lookup)。

在不適用查詢表的情況下是沒有必要首先創造這麼一個表的,所以這會留出大量的內存,所以我們可以在一系列的entries以及被存儲的單個值的緩存中使用多一點的內存。

最初的代碼數據看起來是這樣的:

11

AnimatedVariablePrecomputed對象是從AnimatedVariable對象中構造的,並且從中插值和構建瞭特殊大小的table,Evaluate()隻是在預計算對象上被調用。

我們把AnimatedVariable類改成瞭這樣:

12

我們增加的緩存值mSingleValue和一個數字mNumValues,這樣可以知道什麼時候使用這個值,如果mNumValue是1,Evaluate()就會直接返回mSingleValue,不再需要任何後續的處理,你還可以發現它減少瞭緩存丟失。

最初的Evaluate() method是這樣的:

13

我們的新Evaluate () method如下圖VTune中所示,你可以看到三個可能的執行方案:單數值(紅色)、線性插值(藍色)和預處理查詢(綠色)。

14

這個新代碼運行速度幾乎快瞭3倍:現在這個函數在最慢的函數列表排名中從第三降低到瞭第22,不僅速度更快,它使用的內存也更少瞭,已經低於750kb。這還不算完,它不僅變得更小更快,對於線性插值變得也更加精確,可以說是一石三鳥。

我這裡沒有展示的(本文已經太長瞭)是獲得這個方案所經歷的調整過程,我第一次調整的時候是嘗試在粒子周期的基礎上降低樣本tables的大小,由於有更小的樣本table,一些快速移動的帶粒子(ribbon particles)出現瞭鋸齒。幸運的是這個情況很早就被發現瞭,我也及時換成瞭這裡所展示的版本,我們還做瞭一些不改變性能的數據和代碼調整。

總結

我們這裡所講的是《英雄聯盟》小部分代碼優化的典型案例,這些簡單的改變節約瞭內存使用而且提高瞭粒子線程的運行速度,從而讓主線程執行起來更快。

這裡提到的三個步驟雖然看起來很顯而易見,但經常會被程序員們在做優化的時候所忽略。這裡再重復一次:

確定問題:分析並找出表現最差的部分;理解問題:瞭解這部分代碼的作用以及為什麼會慢;優化解決:基於步驟二改變代碼然後重新分析。如果沒有變得更快,就不斷的重復這個過程。以上的解決方案可能並不是最快的版本,但至少它的解決方向是正確的,這是通過優化獲得性能提高的最安全的方法。

Comments are closed.