Lua性能優化(一):Lua內存優化

本文章僅供稿於知乎、UWA和我個人網頁,未經允許禁止轉載。

大家好,我是舒航,現任職於心動網路,主要負責RO的優化工作。今天主要想和大家分享一下,我這段時間在Lua性能優化方面的一些經驗。

現在市面上大部分Unity遊戲都能支持熱更,主要熱更Lua和一些資源。而Lua主要實現一些UI界面之類的非核心邏輯,這樣雖然Lua這部分代碼非常靈活,增加功能、修復BUG都非常方便。但是由於Lua實現的大部分都是UI,那麼lua熱更出去的功能最多就是一些新UI界面罷了,或者用資源配合配置表來擴充下遊戲既有內容。如果想做個特殊的玩法勢必受到掣肘,一般最後都會發現導出的介面不全,非得打整包才行。為了使得RO的玩法更靈活,熱更的遊戲內容更豐富,其實就是為了滿足策劃們的各種需求啦,RO戰鬥邏輯的主體都是在Lua中完成。所以RO相對於其他的遊戲,對Lua代碼的性能要求會更高一些。

在我對Lua代碼進行性能優化的時候,主要分為兩部分:

  1. 內存優化
    1. 常駐內存優化
    2. 內存分配優化
    3. 內存泄露優化
  2. CPU優化

這篇文章主要是和大家分享第一點,在內存優化上的一些經驗和手段。在我進行Lua優化之前,我們已經請過UWA團隊進行過專業的診斷了,而且收效很好,在此給UWA團隊贊一個。那麼,面對一個已經進行過一次徹底優化的項目,要進行更深入的優化之前首先要問自己一個問題:

我為什麼能比前人優化得更徹底,做到前人沒有做到得事情呢?

總的來說就是兩點:

  1. 工具更先進
  2. 項目更熟悉

所以在這樣的策略下,我主要是針對我們的項目編寫了兩個工具,LuaMemoryMonitor和LuaProfiler。一個用於優化內存泄露和內存分布,一個用於優化內存分配。有了更好的工具之後,再針對性地優化自己項目的代碼,無論是效率還是結果都非常好。LuaMemoryMonitor能在十幾分鐘內就定位到泄露的代碼,用這個工具我們一下午就查清了戰鬥中的內存泄露,並且能清晰地列出Lua的內存分布。而LuaProfiler幫助我們把Lua內存分配速度降低到了原來的40-50%。

LuaMemoryMonitor

LuaMemoryMonitor主要由兩部分組成:

  1. C庫Snapshot
  2. UnityEditor

編輯器部分截圖如下:

一、 C庫的實現細節

C庫的主要工作有:

  1. 快照_G
  2. 計算Lua對象內存大小
  3. 儲存快照

1. 快照_G

Lua中可回收的對象遞歸關係如圖:

遍歷_G時按以下的結構進行遍歷,只需要編寫5個函數traverse_object、traverse_table、traverse_function、traverse_userdata、traverse_thread,然後從traverse_table(_G)開始遞歸即可。在統計內存大小時,即要統計對象本身佔用的內存,也要遞歸地遍歷它所引用的所有對象佔用的內存。其中string是Lua內部管理的,統計時要注意同一字元串的不同引用不能重複統計內存。

有興趣的詳見Lua源碼lgc.c中propagatemark函數

2. 計算Lua對象內存大小

對Lua當前內存進行快照時,我主要是快照_G,如果有需要的話可以快照Registry。如果僅僅是查內存泄露,那麼不統計_G內每一Entry的內存大小也可以。但是我為了分析內存分布,那麼必須得統計每一Entry的內存大小。統計Lua對象的內存大小是沒有現成的介面的,我這裡給大家提供一種思路,也是Lua源碼中統計對象內存大小的辦法。例如一個Table佔用的內存大小為:

size = sizeof(Table) + sizeof(TValue) * h->sizearray + sizeof(Node) * (isdummy(h) ? 0 : sizenode(h));//還要加上Table引用的所有GCObjectsize = size + sizeof(metatble) + sizeof(keys) + sizeof(values);

3. 儲存快照

由於要對Lua內存進行分析,那麼就不能把快照又存到Lua內,這樣很容易干擾自己的數據採集。這也是把快照的代碼寫在C里的一個重要原因,如果是在Lua里寫的話,就必須要小心翼翼地處理這些數據了。而寫在C里就很好解決了,我在C里用一顆多節點樹來儲存每一個快照,多個快照組成一個鏈表。

二、 UnityEditor

1. Editor特性

Snapshot庫已經為編輯器準備好了所有數據了,現在只需要想一個好主意、好方法來利用這些數據。這裡我做了一個特別的功能,可以很高效地利用這些數據。在UnityEditor里可以對每個快照進行邏輯操作。

  • 求交集
    • 即兩個或多個快照中都存在的對象,即這些快照中的常駐內存。
  • 求補集
    • A在B中的補集指在A中但不在B中的對象,即A相對於B的增量。

簡單例子:在剛進入戰鬥時採樣得到A快照,戰鬥一段時間後採樣得到B快照,離開戰鬥場景回到主城,手動GC後採樣得到C快照。求AB的補集得到一個新的快照D,D即是戰鬥期間新增的內存。求AC的補集得到快照E,E即是戰鬥期間新增並且離開戰鬥GC後沒有釋放掉的內存 。對D和E求交集,得到快照F,F即是戰鬥中新增但是回到主城後釋放掉的內存。這其中E中的對象非常有可能就是泄露了的內存,而F中的對象是可以嘗試更早地釋放的內存。這時可以選中E快照,把E快照輸出到Editor上,輸出為一個樹狀結構,就像Unity自帶的Profiler中一樣,如果有泄露的話基本上就無所遁形了。而直接輸出A快照的話,就能得到剛進入戰鬥時的內存分布。

三、 常見泄漏:C#代理

我們項目中查到的比較多的泄露就是C#代理了,如果把Lua匿名函數註冊給C#的代理,那麼這個Lua匿名函數將不能正確地被LuaGC了,也就是泄露了。改進方法就是不把Lua匿名函數註冊給C#代理,這樣的話,每隔一段時間C#都會主動Dispose。

四、 其他內存優化:常駐內存優化

常駐內存方面我們一直控制的很好,這次我們主要是優化了大量的table配置表,這個優化主要參考了UWA中的這篇文章Lua配置表存儲優化方案——盧建。

五、 LuaMemoryMonitor改進

現在LuaMemoryMonitor定位是一個快速定位泄露的工具,還需要提前知道疑似泄露的地方,然後有針對地採樣。有一個改進方法是,在Lua中寫一個定期檢查內存疑似泄露的工具,然後在疑似泄露的地方用LuaMemoryMonitor進行快速定位。

LuaProfiler

解決了常駐內存和內存泄露的麻煩後,發現內存增長的很快,很短時間內就會到達我們項目設置的閾值,迎來一次GC。而剛GC完空餘出來的內存又會迅速地被分配出去,內存長時間處於高位。頻繁分配內存不僅降低了性能,還使手機更容易發熱了。為了定位和優化內存的高分配,我模仿Unity的Profiler寫了一個LuaProfiler,並做了相應的擴展。這個工具也由兩部分組成:

  1. C庫
  2. UnityEditor

一、 LuaProfiler的C庫

LuaProfiler的C庫主要完成了數據採集的工作。它在Lua虛擬機中註冊了鉤子函數,每次Lua Call 和 Return 的時候都會觸發回調。在每次回調的時候,在C里維護了一顆限制層級的多節點樹 ,由C#主動來取這顆樹。每幀都取每幀都清空,即是逐幀統計。每隔一段時間取,但不清空,即是累計模式。

二、 LuaProfiler的UnityEditor

Editor的主體是一組遞歸繪製的foldout控制項,在Editor上顯示出一個樹狀結構。LuaProfiler能實時地根據調用層級,然後通過各個按鈕實現各種規則排序。這和UnityProfiler相同,想必大家不會陌生。另外LuaProfiler不僅提供了和UnityProfiler一樣的逐幀模式,還提供了統計模式和累計模式。在累計模式下,會把每一幀的內存分配累加起來,以樹狀結構的方式展示出來。而統計模式則是把每一個函數的內存分配累加起來,以Top10的形式展現出來。統計模式不關心調用層級,只關心所有函數中哪些函數分配的內存最多。在這樣一個工具的幫助下,RO的內存分配優化變得格外簡單、高效。

三、 常見優化:string.gsub和string.gmatch

在我們項目中,用這兩個string庫函數完成了一個計算中文個數、長度的工具函數。但是容易被忽略的是,string.gsubstring.gmatch會產生大量的子串,這些子串都會開闢一片內存,而我們根本用不上這些子串。我發現函數1在很多項目中都普遍存在,但是用函數2會更好一些。

function getTextLen(str) local result = 0 for uchar in string.gmatch(str, "[%z1-127194-244][128-191*") do result = result + 1 end return resultendfunction getTextLen(str) local byteLen = #str local strLen = 0 local i = 1 local curByte local count = 1 while i <= byteLen do curByte = string.byte(str, i) count = 1 if curByte > 0 and curByte <= 127 then count = 1 elseif curByte >= 192 and curByte < 223 then count = 2 elseif curByte >= 224 and curByte < 239 then count = 3 elseif curByte >= 240 and curByte < 247 then count = 4 end i = i + count strLen = strLen + 1 end return strLenend

### 四、 常見優化:Lua中string是不可變值

這一點也經常被大家忘記,哪怕是寫Lua的老手。在以下代碼中,因為Lua的string是不可變值,每次拼接都會產生一串新的字元串。第6行會產生"仙境"、"仙境傳"、"仙境傳說"一共3串字元串,但是我們只是需要第三串而已(「仙」字被Lua背部重用了)。這無形中就多開闢了一部分內存,我們可以對以下代碼進行優化,從而避免浪費。這種疏忽經常出現在I/O聊天頻道處理配置描述欄位時發生。

1 local tab = { "R", "O", "仙", "境", "傳", "說" }2 3 function getName()4 local ret = ""5 for i = 3, #tab do6 ret = ret .. tab[i]7 end8 return ret9 end1011 function getName()12 return table.concat(tab, 3, #tab)13 end

五、 常見優化:內存池

如果想降低內存分配速度,使用內存池復用對象是必不可少的。在Lua內存池的使用過程中,最容易出現的問題是,忘了放回池子以及池子大小不合理。

總結

全文上下,用到的技術都不難,相信大家都能搞定。在這裡主要是和大家分享一下拿到原始數據後,如何處理過濾數據、信息的經驗,從而更快更準確地定位問題。如果大家有更好更精準的處理數據、過濾信息的方法請不吝賜教。

  • 郵箱 inkiu0@gmail.com
  • QQ 286553528
  • UWA群內ID ink_U0

推薦閱讀:

Unity/C++混合編程全攻略!——基礎準備
UWA 兩周年慶活動第一彈!四場技術直播領跑六月充電季!
基於 Unity 引擎的遊戲開發進階之 全局光照
在Unity中復刻《超級馬里奧》
用好Lua+Unity,讓性能飛起來——Lua與C#交互篇

TAG:Unity游戏引擎 |