Scott Wardle 在 CppCon 2015 上的分享題為《Memory and C++ debugging at EA》,是關於記憶體和除錯方面的一些心得。這裡是視訊連結(需梯子),和演講稿連結。
Scott 同學有 20+ 年遊戲開發的經驗,這個分享包括了在不同時期 (2000 年的 PS2 時期,2005 年左右的 XBox360/PS3 時期,當前的 PS4/XBox One 時期) 的技術演進情況,在此分享中,我收穫頗多,這裡簡單記錄一下。
以 [GL_Note] 開頭的是我夾帶的私貨,見諒。
2000 年左右時的一些原始工具和策略
2000 年左右時,不少程式設計師都是從 C 轉過來沒多久。那時的常見做法是像下面這樣過載 new:
和這樣:
debug_name 用於標示用途,flag 標示分配方向等選項。相信大家都這麼幹過吧。
記憶體中的佈局是這樣的:
除了一塊專用的“小塊記憶體分配器”外,其他一整塊地址空間,從兩端開始往中間用。
對待記憶體碎片化的處理主要是按照生命期,把臨時的短暫的記憶體放前面,較長的放後面。
上圖中的典型例子就是在 Low 這邊把貼圖讀入記憶體,再解壓縮到 High 那頭,保證短期的和長期的互不干擾,就不易形成空洞。
2005 年左右的進展情況
到了 2005 年也就是 360/PS3 的年代,開始支援多分配器:
C++ 裡面過載 delete 是不能有引數的,所以析構時還要手動傳分配器,比較痛苦。
[GL_Note] 這個問題實踐中可通過直接在分配出的 block header 裡存放 allocator 的指標來解決。但這樣會為每塊記憶體浪費 4 個位元組,當然通常 allocator 數量很少,一個 byte 也許就夠了。
[GL_Note] 還有一個見過的做法是,規定凡是自定義的 allocator 都劃分自己專屬的一段記憶體,拿任意一個指標/地址過來,通過對某些區間位做一下位運算,就能判定是哪個定製的 allocator,如果都不是的話,就是預設的 allocator。這種做法用地址空間上的限制消除了額外記錄的需求。
更好的利用地址空間的策略 (以時間,尺寸和不同的團隊邊界等作為標準):
[GL_Note] 簡單解釋一下,
- 第一行時間因素:時間條上從左向右的每一項的生命期都顯著不同,把它們彼此標記和隔離,有助於從根本上避免產生碎片。
- 第二行尺寸因素:按照尺寸儘量把不同量級的記憶體分開,可以讓新的記憶體請求更有效地 fit 進已有的空洞,從而提高利用率,降低極限情況下的最大尺寸開銷。
- 第三行團隊因素:按照團隊切分,能有效地快速定位問題到不同的組 (就能快速找到負責的人) 這裡的 SBA 是 Small Block Allocator。 這三種特徵通常需要綜合起來考慮。
不管以何種方式分塊,塊與塊的邊界處的 corruption 都是比較難以處理的,如下圖:
他們意識到,如果像之前那樣把一些除錯資訊放在新分配的記憶體的尾部,當發生 corruption 時十有八九就會被寫壞,妨礙查錯。於是就單獨開了個除錯堆,把地址尺寸分類標示等除錯資訊 hash 後存在這裡。
這樣當 corruption 發生時,可以精確地找到當時的時間和空間的上下文,看看發生了什麼。
所有這些資訊同時被記錄在硬碟上,如下圖。
可以選擇和檢視任意一個時間點上的分配情況,也可以選擇一段時間區間,檢視在那一段時間裡變化的部分。每一個分配都可以檢視對應的堆疊資訊。
這是 BlockView,可以從空間上直觀地看到不同型別記憶體的分配情況,以及空間上不同區域的利用率和碎片化的資訊。
當選中一個 block 時,可以看到那個 block 相關的詳細資訊 (左下角)
另一個強力工具是 Stomp Allocator:
這個專門用來查 corruption 的。當記憶體請求發生時,它利用虛擬記憶體分配 4k 的可讀寫記憶體並返回尾部的可用空間,並在後面追加一個 4k 的只讀記憶體,這樣一旦發生越界寫立刻就會 crash。這個工具因為記憶體開銷大,所以總是在已經定位到較小範圍內的懷疑物件時使用。
關於智慧指標的迴圈依賴問題,
Scott 說如果加上迴圈依賴的檢測就開始變得像垃圾回收了。所以明確使用規則,避免濫用即可。
[GL_Note] 簡單解釋一下,
智慧指標的使用規則很簡單,一句話就可以概括:當生命期明確的時候,使用 unique_ptr;只有當需要共享物件/資料的所有權導致生命期不確定的時候,才使用 shared_ptr。
這條規則隱含著一個認識:在絕大多數情況下,相互依賴的雙方,必有一方生命期是相對確定的,否則常常說明有隱含的設計問題。
接著 Scott 說到了 EASTL,
在 188 個單獨的測試中,大部分比最新的 VS2015 自帶的快,debug 版更是快上兩個數量級。
[GL_Note] 除了執行速度,一直以來我驚訝的是 EASTL 的良好的可讀性,不得不說這是諸多 STL 版本里,最接近寫給人看的版本。試舉一例,摘自這裡
接下來 Scott 講了一些 EASTLICA (EASTL ICoreAllocator) 的實現細節和一些傳參和 type erasure 的問題處理。這些問題都屬於 stl 定製 allocator 相關的問題,在網路上討論也很普遍,實際上因為 EASTL 是一個專屬版本,在這個專屬環境下問題更容易協調和解決,這裡就不多說了,感興趣的可以直接看視訊。
目前 (2015) 的系統
他們對逐漸開發出來的各種除錯工具進行了強力的整合,下面逐一介紹。
記憶體除錯工具改進
首先是記憶體分配的介面逐漸不再使用一個單一的 debug_name (因為這種單個的字串標籤提供的資訊量太小了),而是使用了 scope 這個上下文相關的概念,來把更多的資訊關聯到這次記憶體分配,比如跟對應的資源名及子系統名掛鉤。
其次,現在任意一個 allocator 都可以方便地找到自己所在的上一級記憶體區域 (parent arena),可以根據這個調整自己的行為。
比如下面這個類 (其中的 eastl 使用了上面提到的 EASTLICA)
由於可以利用這些額外的資訊來定製分配策略,邏輯上相關聯的物件在物理上也會分配在一起,最終在記憶體中的佈局可能是下面這樣:
除錯工具 DeltaViewer
DeltaViewer 會記錄遊戲執行從頭到尾的整個 session (one run of the game),上傳到一個 http server,並存在資料庫裡。
日誌 (Trace Log)
首先是日誌 (Trace Log) 的記錄和檢視:
IO 負載剖析器 (Turbo Tuner)
IO 負載剖析器 (Turbo Tuner) 是一個檢視任意時刻 IO 負載的工具,用這個可以很直觀地看出系統效能受到 IO 影響的情況。
注意這裡的 Bundles 是需要同步載入的完整資源,Chunks 是可非同步載入的碎片資源。
仔細地看可以看到,上面第一行的 http log 可以看出任意時刻的 Log 量的大小和頻繁程度;bundle states / chunk states 這兩欄可以看到 IO 在不同狀態間切換的時間點。
關聯使用
Trace Log 跟 Turbo Tuner 這兩個是關聯的 (實際上後續介紹的這些工具相互之間都是相關聯的),也就是說對於一些關鍵的時間點,如果在日誌中選擇了對應的一條記錄,可以精確地看到那個時間點上發生了什麼,如下圖:
可以看到不同的遊戲階段,以及系統資源隨時間流逝的變化情況,從而得到巨集觀的執行狀況。
當滑鼠懸停在任意一次 bundle request 上時,可以得到那一次請求的所有相關的細節,如下圖:
可以看到有請求 ID (Sequence Number) / 序列 ID (可用來查前後時序相關的問題),StartTime/EndTime/Duration (起始,終止和持續時間),Priority (優先順序),Size / Patch Size 尺寸相關資訊,所在的資源包名 (bundle name),等等。
Performance Timer
接下來是效能剖析器 Frame rate and Job thread profiler (Performance Timer)
最上面一欄是幀率,每個藍色條紋就是一幀。用滑鼠選中就可以高亮那一幀及相鄰的幾幀。下面則依次是幾個 CPU 上的負載情況,可以看到棧呼叫的層次關係和時間開銷,很像 Telemetry 這一類工具,就不多說了。
這個工具跟前面的工具結合起來使用,看起來是下面這樣子的:
Memory Investigator
接下來是使用 Memory Investigator 查詢記憶體洩漏。
傳統意義上的記憶體洩漏是一個寬泛的概念,new 了之後只要最終 delete 了就不算記憶體洩漏。而在遊戲裡這個概念要嚴格得多,在關卡與關卡之間嚴格來講不允許有累積的未釋放記憶體,當第二關的載入結束時,理論上第一關範圍內分配的記憶體都應已被釋放。
用這個工具可以選擇一個時間段 (A-B) 和一個時間點 C,然後列出在 (A-B) 這段時間內所有到了點 C 仍未被釋放的記憶體分配,並檢視它們的各種相關資訊。
也可以檢視不同的時間點上,記憶體的分類對比情況
可以看到不同尺寸 (512B/64K/2M/Large) 的記憶體被分類統計,其中一百多次大分配佔據了 1.7G 左右,而兩百多萬的小分配佔據了 100M 左右,這有助於我們更細緻地瞭解記憶體的使用狀況。
這是按照資源模組分類的情況
小結和問答
在後面的問答中,有人問這個工具會不會開源,Scott 說目前不會,但 EASTLICA 可能會隨著 EASTL 一起開源,所以日後也不排除這個可能性。關於 EASTL,有同學問效能提升主要來自哪裡,Scott 回答說主要是 1) 用指標做 iterator 和 2) 不依賴 inline 把很深的巢狀呼叫拍扁。有人問獲取這麼多資料會影響遊戲的執行效能嗎,Scott 說他一直都很驚訝於這個工具的執行效能,遊戲實時執行沒有問題,基本上只會損失 10%-20% (3-4ms)。
這個分享的資訊量挺大,很多思路都非常有價值。受益匪淺,簡單記錄,以備日後參考。最後再提一下,如果我的細節描述不夠,請移步前往視訊以獲得完整的內容。