The world at your fingertips — 天涯明月刀幕後(10)回收
垃圾回收
引擎中,對象生命周期也要更好的管理起來。
整個項目使用的舊引擎框架,對於對象生命周期的管理,是比較簡單的,依賴於引用計數。我一直不太喜歡引用計數,因為它對指針加入了額外的內存開銷,同時在指針操作上,加上了額外的引用計數的操作,再加上需要考慮多線程操作指針時刻的引用計數安全性,也會帶來額外的性能開銷,還會引起循環引用導致的內存無法釋放問題。
所以之前的幾個版本都沒有好好處理對象生命周期,對象全依賴於程序員手工創建和釋放,反正demo規模小,能扛住。但這次要做的demo版本規模稍大,是時候好好管理這一塊了。
我開始做一個垃圾收集系統(Gabbage Collection),也是比較簡單的Sweep and Clean體系。我們把對象分成Managed和Unmanaged兩部分,對於Unmanaged的那部分對象,程序員必須自己去管理。
對於managed那部分對象,主要就是遊戲內用的對象,由於我們已經在整套序列化體系中標註出指針,那麼掃描指針變成一件非常簡單的事情,我們在整個遊戲循環後,線程安全的地方,做一次Sweep,把被Reference到的對象標註出來,把其他沒有被引用的對象放到pending delete的隊列,過幾幀後就可以安全的刪除了。
這樣做,帶來了很多好處。
換關卡等操作變得極其簡單,只需要把整個關卡的根指針賦值NULL,然後做一遍垃圾收集工作,所有關卡裡面的無用對象,由於丟失了到根結點的引用路線,就被刪除了。
更有趣的是,如果我們有足夠的內存,那完全可以先載入一部分新的關卡,然後再刪除舊關卡,這樣新舊關卡共用的那部分object,就不用被刪除和重新創建,僅僅需要reset一下內部的變數值,就可以自然地被重用,節約開銷。
考慮到網遊一般都是大地形streaming,我們事實上不停在做物件刪除和重建,垃圾系統在整個過程中,能最大限度重用新舊系統共用的Object,減少IO和內存分配的壓力。當然這個問題Reference count系統也可以做到,不值得特別展開。
平時的指針操作都沒有性能開銷,只在垃圾回收的時候才有少量開銷。這一點聽上去似乎不重要,我們只是把一部分開銷從一個地方移動到了另一個地方。但實際非常重要,遊戲引擎中,性能的可預測、可管理極其重要。在引用計數系統裡面,整個系統是均勻的變慢,你並沒有太好的方法去優化它。但在垃圾回收系統里,我們平時運行是不需要引入這個系統的,我們只是定期運行垃圾回收系統,即使有較大的開銷,也是一個可控的情況。對於集中的開銷,後續我們還開發了分幀的垃圾回收系統,可以在一個固定分配的時間片預算裡面,進行GC的操作,這樣我們就可以順利抹平性能消耗的尖刺,達到平滑的幀數。
垃圾回收系統還帶來了更多有趣的用法。
考慮到多線程框架中,邏輯線程和渲染線程往往耗時不一樣,這些線程在同步點上有可能會互相等待。有了我們的比較通用GC時間片系統,我們可以在兩個線程互相等待的時候,在空閑cpu上插入少量GC的工作,讓它們儘可能多的利用足cpu時間。反正GC標註工作是一個循環往複的工作,再多的CPU都是有價值的。
再比如在Editor中的對象Copy/Paste,本來並不是特別容易做的一件事情。對於沒有外部引用對象的情況,相對比較簡單,直接複製一個就好。但如果一個對象還引用了別的對象,就會比較複雜。有幾種情況需要分開討論。一種是引用的對象,從屬於這個對象,這種情況下我們需要把這個對象以及引用對象一起複制一份,並且讓舊的對象引用它,這個我們稱為Deep copy。另一種情況是引用對象是公共的對象,比如關卡中其他物體,這個情況下,我們不能簡單的複製這個新的對象,而是直接引用它,這個我們稱為Shallow copy。考慮到每一個對象可能都會引用其他對象,我們的整個過程必須是遞歸的選擇shallow copy和deep copy。好在所有的引用都已經用垃圾回收系統標識出來,僅僅需要根據引用數據的類型,打上shallow copy或者deep copy的標記,就可以比較簡單的做到這個事情。
垃圾回收系統也可以對磁碟上冗餘資源文件做清理,所有訪問不到的對象就是沒用的,遊戲打包的時候也不需要帶上那些對象。在編輯器中做一次全關卡的GC操作,就知道什麼對象有價值,什麼對象沒有用,輸出列表後,把無用的對象一一物理刪除,就完成了磁碟資源清理。
當然沒有免費的午餐,GC系統的缺點也是有的。回收體系變得更複雜,程序員的理解成本更高了。而且日常開發過程中,有不少開發規範要遵守,對新加入團隊的程序員來說,學習門檻變高了。早期不夠完善的時候,也可能引入一些隨機crash,非常難查。
總體來說我對新系統還是很滿意,垃圾回收體系對於簡化對象管理、性能控制等各方面,都有很大的好處,也提供了不少side effect,可以幫助工具集做得更好。
GC優化
說完垃圾回收,說一下後續做的一次優化。
垃圾回收系統啟動一次,往往要數十毫秒,可能引起性能的波動。為了降低性能的波動,我們做了分幀的回收優化,每一個frame分配了3ms供系統使用,垃圾回收系統每幀都跑,用滿3ms就退出,不再進一步消耗性能。經過多幀的垃圾回收,終於完成mark所有對象的時候,我們就把系統中的多餘對象做上標記,過幾幀全部刪除。之所以要過幾幀刪除,主要是考慮多線程問題,還有可能有其他線程正在使用這個變數。
如果GC能跑快一點,我們就能更及時回收垃圾,對工具效率和內存管理也會有一定的收益。
當時正值春節前夕,同伴們紛紛離開了辦公室。每逢佳節,手頭需要做的雜事瞬間收斂,不再需要為瑣事奔忙。既然大家都退散了,還有半天就要休假,那就不考慮長遠的工作,抽空看看代碼,看有什麼可以優化的吧。
我看了一下內存統計的log,注意到每次GC,都會有30000多次的內存分配,這個顯然太多了,從那裡入手看看吧。
要優化,先建立測試體系。我在一個指定的場景,Loading完地圖,等待固定的幀數,做了一個GC,且不分幀,一次做完,盡量確保測試條件穩定。然後啟動GC,自動輸出一個垃圾回收到結束後所需的時間。做完這個,我統計了一下當前GC的時間,作為基準參考。然後我就禁用了編譯器對代碼的優化,便於調試,再另外輸出一個GC時間,作為優化的起點,開始下一步的優化。
整個GC系統分成三部分,首先清理所有對象池中的對象標記,確保所有對象的標誌都是乾淨的,然後循環對所有根節點對象進行序列化操作,通過指針對象訪問到其他對象,一一打上標記,最後再遍歷一遍所有對象,沒有標記的對象都是可回收的對象。
原始的版本,關閉編譯器優化後大約需要19.6ms。
第一想到的是,內存分配是不是太多了,一次GC有33000次內存分配,原因出於在遍歷指針對象時,會隨手把後續需要遍歷的對象push到一個list裡面,這操作會導致內存重新分配。由於這個容器需要push_back,也需要pop_front,所以無法使用vector,我隨手把容器類型改成了deque,分配操作變成了26000次,但再嘗試GC,依然要19ms,這次嘗試順利的失敗了。
deque還是分配太多,我們更簡單點,直接做一個定製容器,一個resize,一次分配好需要的內存,push_back和pop_front就會變成很簡單的指針加減操作。GC結束後統一釋放這塊內存即可。馬上分配操作的時間變成了15500次,時間有19.6ms變成了16.6ms。
本想回家休假了,但初戰告捷,自然要乘勝追擊。
繼續看內存分配問題,還是有15k的內存分配,來自於一個輔助類的clone操作,這個操作需要分配很少的內存,在同一個函數里先分配,用好就在同一地方釋放。這個有點浪費,我想從演算法角度處理一下。但我看了一下演算法,這個clone類可以隱藏template的細節,簡化複雜的模板代碼,所以也不容易改掉。對於local使用的少量內存分配,用動態內存分配太浪費了,我用_alloca的方法,直接把內存分配在stack上,簡單快速,且無需釋放,函數退出的時候自然就回收了。三行代碼一改,內存分配直接下降到2次,時間從16.6ms變成了13.8ms。
繼續努力,我找到一個頻繁被使用的struct,裡面比較複雜,既不是POD,又有一些不必要的欄位。我先去掉了default的constructor,再去掉了一個沒用的欄位,希望memcpy的時候能少copy一點內容,profile一下,效果寥寥,只有0.3ms的減少,可以忽略。
最初內存分配的罪魁禍首被幹掉,我開始沒有方向了,胡亂嘗試了一下,把對幾個容器的操作inline化,無效。
還是要耐心看看代碼,我又注意到了,在遍歷指針的時候,我們先peek一下隊列,取出首部的指針,然後處理這個指針,最後把這個指針pop出來。這個是冗餘操作,既然這個指針遲早要死,為什麼不在第一時刻死呢?我順手在自定義的容器裡面處理了一下,把peek去掉,我們直接pop指針,取出這個pop的指針做後續處理。進一步看,這些指針會被加入待處理隊列,留給後續處理。但並不是每一個指針都要加進去,如果這個對象的指針已經被處理過,就不需要了。我加上一個檢查flag的操作,確保一個指針指向的對象,只在沒有處理過的時候才加入,時間很快從13.5ms降低到了10.8ms。
進一步看,每一個對象和結構都會被完整掃描,但其實並不需要,有很多結構裡面沒有任何指針,這些結構並不需要被處理。在生成對象數據元信息的階段,其實我可以在宏裡面做很多預處理,如果一個對象完全沒有指針,我完全可以在預處理階段標識出來,有了這個標識,我就可以知道這個結構沒有指向外部的引用,就根本沒有必要掃描相關的結構。我可以直接跳過這個結構掃描。做完改動,10.8ms又變成了8.5ms。
後續的思路又開始模糊,還能進一步優化嗎?沒有什麼能夠阻擋,一個進入心流狀態的人,當然要乘勝追擊。
我對待處理隊列做了很多微調,有進步,也有反覆,對很多GC以及對象本身的標誌做了細緻的管理,確保儘可能不把無需處理的對象加入GC系統,說起來簡單,做起來還是很繁瑣的,主要是需要相當深度的測試,確保沒有破壞整個遊戲,工具集也需要看一下,也許隨手調整了一個標誌,就有可能導致GC整個系統失敗,進而會有內存泄露。當然這個工作主要還是體力活,腦力損耗不大,正好逛逛辦公室,心情,一般都是隨著辦公室人數的增多而成反比,今天人不多,心情挺好。做了這些精細調整後,時間消耗從8.5變成了7.2ms。
我在細緻調整的同時,也隨手加了一個新的統計信息,關於所有的引用對象和指針被訪問了多少次的,從這個統計裡面,我注意到了我們有17萬次的引用訪問。這裡也許有戲,對象數量和訪問數量有點偏多。
找到了新的線索,我又來了精神。測試環境下大約有18000個對象,但我們訪問了17萬次引用,有點多。我加了一些log代碼,dump出所有reference信息調用的時候,都是在什麼對象的什麼引用上面,然後我就發現我們有大量的策劃使用的靜態表格,裡面有很多的引用。這個當然不能怨策劃,還是我們需要解決的問題。策劃表格會被轉成XML或者二進位,用和其他遊戲對象一樣的機制被序列化,所以本質上每一個表格也都是對象,但我真沒想到表格之間還要互相引用別的對象,我一直以為表格內部都是純數據。糟糕的是,表格的每一行通常是一個巨大的結構體,也會有很多行,結構體裡面只要有一個指針,就會被重複無數遍,這個指針也需要一次次被重新查找掃描,成為後續GC的負擔,更何況往往結構體裡面會有很多個指針,帶來的負擔就更大。
表格應該是一個靜態的結構,無需被頻繁的分配和釋放,我們是不是應該考慮將其靜態化?並告知GC,跳過這一步?聽上去很靠譜,實際做起來也是,我簡單加了一個GC_Free的標誌,標識出表格。然後在GC掃描的時候,會有檢查對象標誌的地方,也去查一下GC_Free標誌,可以跳過所有帶有這個標誌的對象,簡潔完美。時間開銷進一步減少,7.2ms變成了4.3ms。
進一步做了不少嘗試,去除一些無關的調試代碼,嘗試把一些函數參數全局化,減少頻繁調用的函數開銷,但測試下來都沒有什麼效果,全部revert回去。
最後找到了結構體序列化的地方,有一個Lazy Init結構體元素的地方,是在序列化的時候才做一些數據的初始化,我改成了在遊戲初始化的時候把所有的信息都預處理完,又節約了額外的0.2ms,最終GC一次的開採大約穩定在4ms。
做完這一切,時間也不早了,沒有進一步的力氣深入打磨,我打開了所有的編譯器優化,讓編譯器完成最後的工作,編譯器優化後,大約開銷在2.4ms,完全可以接受了,我隨手把每幀分配給GC的時間片預算從3ms改成1ms,整個引擎Loop終於從GC系統優化中收益,得到了2ms的時間。
一整天奮戰,原始版本的19.6ms,優化到4ms,加上編譯器優化,只要2.4ms,成績不小,我帶著心流的滿足,愉快地開啟了春節放假模式。
推薦閱讀: