https://blog.csdn.net/leonwei/article/details/95527109
在最前面
基於UE的手游客戶端的性能主要由這七大部分構成:CPU邏輯,CPU渲染,圖形API(提交),GPU渲染,內存,帶寬,加載時間。這幾個基本元素又會合力衍生出一些新的性能指標,例如功耗(往往同gpu負載和帶寬緊密相關)。同時這七部分又構成一個閉合的木桶,最長的一塊是主要瓶頸,並且瓶頸可以在這幾塊轉移流動。作為開發者我們解決性能問題的步驟一般都是按照做性能剖析,解讀結果,定位問題,增加剖析代碼,優化問題,重復剖析的迭代過程來執行。而高效准確詳細的對性能進行剖析得到結果是第一步,在任何引擎上,只要我們能做到在任意時刻准確的獲取想要的性能剖析結果,那么才會胸有成竹不會慌,該系列文章將歸納總結在ue下對每一性能指標的剖析方法,做深入分析,我們需要工具的幫助,也需要程序員理解引擎並知道如何去編寫合適的剖析代碼。
最近剛好做過一輪RHI線程的剖析,第一篇就從RHI開始,我會堅持把后面幾篇寫下去。
渲染API瓶頸
渲染API瓶頸是3D手游的常見瓶頸,我們常說的drawcall 過多了,卡渲染就是指的卡在這里,其實這個卡渲染卡的是cpu。為什么drawcall會卡,因為cpu需要通過對渲染api的調用來驅動gpu做事情,1個drawcall的背后是一堆渲染api的調用,下面是一個常見的drawcall過程,
可以看到為了一次繪制(1個drawcall),要設置shader,創建buffer等等,這些相比最后的draw那一步來說都是相對更費時的。
當測試反饋給我們卡drawcall的時候,作為程序我們需要一種手段來衡量出確切的當前做哪些drawcall,或者說繪制哪些東西更耗,最好是精確到耗在繪制哪個模型的哪個api調用上,我們才能真正的給美術予以優化指導。
UE中精確定位RHI瓶頸
在UE中,pc和android平台通常渲染api的調用會放在一個單獨的線程,叫做RHI線程,這個線程專門負責渲染指令的提交,即調用顯卡的API。我們分析渲染提交的卡頓就是要分析這個RHI線程。
多線程渲染工作模型
但是RHI線程不是單獨存在的,它需要同game,render線程協作,rhi的卡頓可能不只是rhi的卡頓,首先需要清楚UE里面RHI線程和其他線程的工作模式:
這里面game render rhi gpu分別在4個並行的工作線上,有這樣幾個特點:
game thread最多可以等渲染一幀,也就是說渲染如果第N幀的渲染在第N+1幀的game tick結束時還沒有完成,那么渲染就會把game卡住,render 和rhi不會有幀延遲。
game是render和rhi的源驅動者,game的卡頓可能會卡住渲染
render 負責產生drawcall,rhi負責提交drawcall,因此render的卡頓也可能卡住rhi提交。
渲染的最后一步要swapbuffer,即等待gpu完成,所以gpu的卡頓也可能會卡住rhi。
除了gamethread本身,render rhi 和gpu的工作都是存在間隙的,即game邏輯喂給渲染任務的時機會影響渲染工作的密度,也會影響到渲染的時間,小量多次會浪費渲染效率。
UE中rhi的瓶頸的來源
現在我們知道rhi的卡頓可能來自於以下幾種情況:
a RHI指令自身的卡頓,即通常所說的卡drawcall,過多的dc,過多的渲染狀態切換,過多的渲染資源創建,等等;
b game或者render thread的卡頓
c gpu的卡頓。
對於情況b,我們可以通過UE的status 看當前的game和render的線程執行時間來容易的判斷出來,來排除是rhi上出了問題。
對於情況c, UE的status中在rhi線程上會統計一個叫做swapbuffer的時間,如果這個時間過長,那么就是gpu瓶頸了。
真正比較麻煩的是定位情況a,即對於rhi指令本身的卡頓瓶頸。對於這種情況UE自帶的stat工具通常不能給出比較有力的分析結果,自帶的方法只能統計一幀在rhi上做幾種給定操作的時間,但是在復雜的線程條件下,有時很難確定這些卡頓的幕后原因,有時rhi問題只是一個表象,為了得到rhi線程瓶頸的確切原因,我們至少要能夠明確以下幾個事情:
1 Rhi線程的執行是由一堆有序的rhi command組成的,我們要能捕捉到具體的那一個rhi command的執行時間比較長,比如是創建場景中哪個房子的vb?
2 是在render thread的哪一個步驟塞入的渲染數據導致了這個rhi command執行的時間比較長,是在渲染陰影的時候,還是渲染basepass,還是做遮擋剔除?
3 是在game thread的哪一個步驟塞入的渲染數據導致了這個rhi command執行的時間比較長?是在加載場景?還是在繪制UI的時候?
筆者在項目中遇到過一個問題,在一些低端機,rhi會有時突然卡頓幾秒以上,看stat文件如下:
我只能看到在rhi 線程的thcikbegin階段發生了巨大的卡頓,然后就沒有細節了,不知道是具體哪個rhicommand,然后看gamethread在wait,也不知道是game thread的哪一步觸發了這個rhi瓶頸。我們需要一些辦法。
定位UE中rhi線程的瓶頸
我們需要分別將上面三種原因捕捉到,就能解開這個問題。
首先定義一個宏,只有我們需要捕捉這些詳細的rhi瓶頸時開啟,因為這些操作會存在較大的overhead。
定位具體rhicommand的時間
對於rhi command的具體執行時間,我在FRHICommand的最終執行階段ExecuteAndDestruct中創建一個FScopeCycleCounter,counter的名字就直接rtti當前command的typename。
有時候我們需要更細節的知道這個command除了類型外的信息,例如如果這是一個createvb的command,那么vb的原始模型名字是什么,vb大小等,我在一些command處額外傳了一些debug用的string,然后在這些command的執行前補上一個FScopeCycleCounter。這樣我們就能拿到精確到具體rhicommand的提交耗時了。經過這個補充,我能拿到這樣的rhi 線程執行時間統計:
這樣謎底就清晰了很多,原來這時候存在大量的vb創建,數了一下,有幾百個,在同一幀內幾百個vb的創建,在低端android上會產生5秒鍾的超級卡頓,那么問題來了,為何在這一幀會同時產生這么多的vb卡頓,是game或者render 上發生了什么事情,如果我們查看當前的game thread ,它顯示的是wait,是不知道原因的,因為game ,render ,rhi是分開工作的,我們現在rhi處於瓶頸已經不是事故的”第一現場”了,我們需要進一步讓你發生在第一現場。
定位在render的哪個階段發生了rhi的瓶頸
UE的rhicommandlist自帶了一個函數FRHICommandListImmediate::SetCurrentStat,可以用來讓render給rhi加一個標記,這個標記就可以認為是render的某個階段的名字,UE自帶了在render 的很多階段下了這個標記,我們還可以自己補充,這個函數的原理如下:
這個status本身也是以command的形式插入隊列,所以每一條rhi執行的cmd會被統計到它之前最近的那個status tag下面,通過不斷的細分插入這些tag,我們可以跟蹤到rhi的cmd從是在render的哪個階段被產生。需要注意的是這個tag只能在render thread里插入。我為render thread補充了一些細化的tag后,如前面的圖,我發現這個大量的vb創建發生在渲染線程的一幀渲染結束到下一幀渲染開始之前,在這個階段有game 邏輯往render 里面堆入了大量創建vb的指令,所以問題還要繼續往game thread 上找”第一現場”。
定位在Game的哪個階段產生RHI瓶頸
其實我們仍然可以模仿renderthread一樣在game thread上給rhi的command list里面插入tag,但是有個問題,renderthread是一種相對簡單的render command的隊列的順序執行, tag量有限相對容易操作,但是game thread里面邏輯極其復雜,我們希望可以復用game thread上面已經埋好的一些scope counter,不過game和rhi是兩條並行的thread,需要在我們關心的scope處讓二者能夠強行同步住,才能容易的使用game 自己的scope counter抓住rhi的執行。我們這樣去實現,假設下面是我們關心的一個game thread的區段,在前后加上代碼如下
#if STAT_RHI_ADVANCED FlushRenderingCommands(true) DECLARE_SCOPE_CYCLE_COUNTER(TEXT("XXX "), STAT_XXX, STATGROUP_RHI_GAME_SYNC); #endif //game 代碼段 … … // #if STAT_RHI_ ADVANCED FlushRenderingCommands(true); #endif
FlushRenderingCommands(true)的意思是在這個位置強制將所有當前的rhicommand執行完畢,阻塞住當前線程,所以上面這個代碼段的原有的XXX統計的時間將包括這段時間內因為game thread上發生的渲染事件的渲染而花費的時間。
通過在game thread的主要邏輯處,插入這些同步rhi線程的代碼,當rhi線程發生瓶頸的時候,我們只要查看當前gamethread在哪里停住(wait event),就可以判斷是什么game 邏輯導致了rhi線程的瓶頸。有了這個機制,我們接着截stat文件,會看到當rhi處於巨大瓶頸時,game thread停在了這里:
凶手被抓住了,是一個資源正在被緩存池預加載!
這個奇怪的rhi上的卡頓的真正原因其實是game 線程上在加載一個模型資源!如果只依靠ue本來的stat分析,是無論如何都不可能猜到這個幕后的凶手的。
那么問題來了,一個資源的加載為何會導致海量的vb同時創建?通過進一步的分析代碼,會發現因為這里用的是同步加載,而UE的同步加載的機制,是創建一個加載任務堆到同異步加載一樣的加載隊列里,因為不能保證依賴關系,所以要等待當前所有隊列中的任務完成才能繼續下去,也就是說當前的同步加載的時間絕不僅僅是加載完你要的這個模型而已,他需要將當前異步加載任務在隊列中的所有資源加載完!而這個時候恰恰處於場景在level streaming的階段,最后發現此事加載隊列中的資源上百個,這個同步加載遇上level streaming的結果就是,在這一幀要完成上百個模型的創建,模型的postload會初始化rhi資源,導致一幀內大量vb的創建,卡死rhi,所以罪魁禍首是同步加載,同步加載將level streaming的過程也強行同步了,找到了問題,我們就可以通過相關的優化手段來排除這個瓶頸。
RHI上的問題可能往往不只是rhi上的問題那么簡單,通過上面說的一些方法我們可以清楚的看到各種rhi上瓶頸的真正原因。
CPU幀率瓶頸和卡頓
CPU上幀率低和卡頓是性能優化中最易出現的一部分,尤其對於手游,提到卡,就大概率是在CPU上出現的問題,CPU上的卡頓一般是卡邏輯或是卡渲染,本篇將詳細系統的介紹基於UE的手游對CPU瓶頸的剖析方法。
低幀率和卡頓
首先低幀率和卡頓是兩種完全不同的瓶頸類型,雖然歸根到底都是某個函數執行的過慢引起的,但是定位和解決方法並不一樣。低幀率瓶頸是需要統計一段時間內CPU把更多的時鍾耗費在了哪些函數上,或統計一段時間內各個函數占用的cpu時間百分比,找到百分比高的將其優化,就會使幀率得到整體的提高。卡頓則是在一幀的一次運行內某段代碼的運行產生了比平均情況明顯的長時間,需要定義這段代碼的起始點,分別進行計時,然后在連續的統計數據中找到峰值。簡單來說幀率瓶頸是統計平均的CPU占用,而卡頓是找峰值。
低幀率瓶頸—平均CPU占用
對於UE程序,我們通常有下面一些方法去找到函數的平均CPU占用。一種是基於UE內置的stat機制,另一類是基於各種平台相關工具。
UE的stat機制:
UE自己的stat機制是一種基於埋點的機制,即通過在一段邏輯前后顯示的增加標簽來錄得這段時間這個標簽內邏輯的運行時間。然后利用ue的frontend可視化所有打了標簽的函數的運行時間曲線。這個基於埋點的機制的好處是:不僅可以看到瓶頸cpu占用,也能看到峰值。缺點就是需要人工打標簽,你需要不斷的細分一些標簽去找到瓶頸。詳細的Stat參考文檔包括
https://docs.unrealengine.com/en-US/Engine/Performance/StatCommands/index.html及https://docs.unrealengine.com/en-US/Engine/Performance/Profiler/index.html
Stat的代碼機制是這樣運作的:
首先ue有很多種類型的stat,測試cpu運行時間的stat叫做cycle stat。典型的使用分三步:
第一步:每個stat一定存在於一個stat group里,需要通過下面宏先定義一個stat group,
DECLARE_STAT_GROUP(Description, StatName, StatCategory, InDefaultEnable, InCompileTimeEnable, InSortByName)
這里的InDefaultEnable表示是否默認開啟,默認不開啟的話需要在運行時通過 stat group enable StatNamel來動態開啟。這個宏會定義一個FStatGroup_StatName的結構體。
第二步:定義一個cycle stat,通過宏
DECLARE_CYCLE_STAT(CounterName,StatId,GroupId),這里的groupid就是之前定義的group的statname。這個宏其實是調用一個更加通用類型stat的聲明 DECLARE_STAT(Description, StatName, GroupName, StatType, bShouldClearEveryFrame, bCycleStat, MemoryRegion),它會定義一個FStat__ StatId的結構體,並同時聲明一個全局的FThreadSafeStaticStat<FStat__ StatId>變量StatPtr_StatId,這個變量有個主要的作用是高效率的通過getstatid()接口返回某個給定名字的statid的全局唯一的FStat__ StatId實例。
第三步:測量,定義好之后可以在一段代碼的作用域開始處加入SCOPE_CYCLE_COUNTER(StatId),它會為當前作用域的前后埋點,這statid會用來統計這個作用域處的cpu時間開銷,其實它獲取到全局的這個FStat__StatId用其構造了一個FScopeCycleCounter的臨時變量,它繼承自FCycleCounter,它是個基於scope的變量,在構造的時候會調用FCycleCounter的start,start就會開始設定這個FStat__ StatId的統計,而析構的時候他調用FCycleCounter的stop來停止收集。
所謂收集的過程就是調用
FThreadStats::AddMessage( StatName, EStatOperation::CycleScopeStart ) 通知stat線程去進行一個給定名字的cycle事件的收集,結束則是調用的FThreadStats::AddMessage(StatId, EStatOperation::CycleScopeEnd)。FThreadStats::AddMessage是真正最終讓UE做性能統計的接口,而前面定義的stat group和stat id則是上層的封裝,你完全可以直接調用FThreadStats::AddMessage去給UE增加一個統計,但是這個只會記錄在統計文件里,不能像stat group那樣使用控制台指令實時打印在游戲界面上。
這里面除了上面這種最常規的定義一個cpu時間統計的方法,還有很多其他有用的宏方法:
QUICK_SCOPE_CYCLE_COUNTER(Stat):不需要你事先聲明一個group,也不需要事先聲明一個statid,用這個stat名字作為statid,在STATGROUP_Quick里面定義一個cycle的統計
DECLARE_SCOPE_CYCLE_COUNTER(CounterName,Stat,GroupId):聲明一個在groupid組下的叫做countername的statid,並且立即啟動一個它的scopecyclecounter,這也是一個在代碼里快捷加cycle 統計的方法。
DECLARE_STATS_GROUP_VERBOSE:聲明一個默認不被enable的組
CONDITIONAL_SCOPE_CYCLE_COUNTER(Stat,bCondition):只有在bCondition為true的情況下才統計
此外可以定義上面除了int類型之外的cycle counter之外,還可以定義其他類型,使用
DECLARE_FLOAT_COUNTER_STAT
DECLARE_DWORD_COUNTER_STAT
此外cycle counter還可以使用累計模式,即每幀不清空,即統計的是到當前為止的累計值,使用DECLARE_FLOAT_ACCUMULATOR_STAT這樣的宏
除了對cpu cycle的統計之外,stat系統還可以統計其他一些指標,包括:
DECLARE_MEMORY_STAT 將聲明一個int64的累計的計數器,通常用於統計內存,這種statid通常不用cycle count那種定義FScopeCycleCounter來使用,而是直接在代碼里利用INC_MEMORY_STAT_BY/DEC_MEMORY_STAT_BY 來手動加減,它其實相當於調用FThreadStats::AddMessage() 給他發一個EStatOperation::Add/substrct消息。
當然所有stat都可以調用這個手動加減的接口,甚至還有直接設置每個stat的當前數值的接口SET_DWORD_STAT_FName。
上面列舉了各種眼花繚亂的stat定義方法,但是其實這些多種多樣的統計宏的背后的機制是簡單純粹的,就是在各種使用這個宏定義
DECLARE_STAT(Description, StatName, GroupName, StatType, bShouldClearEveryFrame, bCycleStat, MemoryRegion) 和FThreadStats::AddMessage()這兩個機制。把這個機制抽象起來,可以這樣描述:
1.首先在STAT系統定義了一種計數器,通過上面DECLARE_STAT這個宏去生成一個叫做FStat_##StatName的計數器的類型,這個類型要返回一些接口,用來描述:GroupName-屬於哪個組,StatType-計數器的數據類型 ,bShouldClearEveryFrame-是否每幀清空,還是累加,bCycleStat-是否用來統計cpu cycle,MemoryRegion-是否是對memory的統計,如果是統計的mem類型是什么。
2.定義一個通常是全局的FThreadSafeStaticStat<FStat_##Stat> StatPtr_##Stat來方便的獲取某個stat 名字的statid計數器類型
3.使用FThreadStats::AddMessage(FName InStatName, EStatOperation::Type InStatOperation) 這個機制去操縱某個stat計數器的值。InStatName就是這里的stat的名字,InStatOperation包括的操作包括:CycleScopeStart和CycleScopeEnd -將這段時間內的cpu 時間ms記錄下來加到計數器里, Set-直接設置計數器的值,Clear-清空計數器的值,Add-增加計數器的值,Subtract-減少計數器的值。
所以上面的各種宏只是對上面這三個步驟的各種簡化封裝。
Stat系統給我們提供了一個基於埋點的統計函數cpu時間的機制,它很強大,我們可以通過stat group去動態看到這些時間(那些默認enable的),也可以通過ue的profilor去看各個計數器的時間曲線。但是很多時候當我們不能預感到哪里會有瓶頸的時候,即不知道在哪里埋點的時候,就需要更通用一些的機制。就依托一些平台的工具了。
平台工具
XCode的counter
counter是xcode在instrument里面的一個工具,他可以記錄cpu上每個線程在一段時間內的各個函數的cpu占用時間比,對於ios系統來說,這個是衡量cpu幀率瓶頸的golden rule。Counter看到的具體內容可以如下:
如何從Counter來推測出每個函數的每幀具體時間開銷呢?Counter給的是一個cpu的時間占比,我們可以先看到具體gamethread占用cpu的時間比r,然后從ue的stat unit得到gamethread的每幀時間t,然后對於一個具體函數它的cpu時間占比如果是b,那么這個函數平均每幀的執行時間就是t*b/r.
Android Studio的profiler
Android Studio3.0以上的profiler很強大,如果device是8.0以上的android系統,那么將可以用profilor capture一段時間的c++即android trace。然后可以從圖表中看到當前每個thread中每個函數的cpu占用時間比,執行次數,等等,如圖
還可以看到具體的每個線程每個函數執行的時序,如圖
通過這個profiler不僅可以像xcode的counter一樣獲取所有c++函數的每幀執行時間,找到熱點函數,我們還可以從thead的執行時許上直觀看到多線程之間的函數執行關系,多線程的執行狀態是否合理,比如看到game線程在某個地方需要等待很久某個work線程完成,那么可以嘗試把work再分並行,或者調整某些無關的事情提前,讓game等這個work的同時在做一些別的工作,不要干等。
Android NDK的 simpleperf
對於低版本無法使用android studio profiler調試的可以依賴Android sdk里面的另外兩個有用的工具,一個是NDK的simpleperf,它可以調試獲取c++層每個函數的cpu占用百分比,除了需要用命令行並且輸出的格式沒那么好看之外,同studio的profilor能拿到的結果是差不多的。
Simpleperf的完全使用文檔在https://developer.android.com/ndk/guides/simpleperf,其實主要分為兩步,第一步是用simperperf record命令去采集數據,第二步是用simpleperf report命令去輸出數據。
一種比較簡單的使用方法是這樣的,首先連接手機,運行程序,確保在usb調試狀態下,首先進入ndk的simpleperf目錄下,打開app_profiler.config去配置一些配置,一定要配置的包括:
App_package_name:包名
Android_sudio_projectdir:android sdutio工程路徑,這個在ue工程就是目錄client/intermediate/android/apk/gradle/
Native_lib_dir:這個是用來尋找帶調試符號的so的地址,在UE工程就是client/intermediate/android/apk/jni/armeabi-v7a/這個目錄,因為shipping版本的符號沒有,所以這里要提供在develop等版本編譯出來的。
Apk_file_path:這是你的apk的路徑
Main_activity:這個對於UE程序一般默認是com.epicgames.ue4.GameActivity
Record_option: 這個比較重要,要參加文檔,是record的參數,例如”-e cpu-clock:u –duration 5”就代表采樣cpu時鍾數,並且僅監控用戶空間,采樣5秒。至於這里-e還可以采集哪些東西,你可以執行adb shell run-as com.xxx.xxx ./simpleperf list來列出來。
Adb_path:這里要填本機的adb工具的位置
配置好了,我們可以先啟動你的可調式版本的程序在手機上,不能是shipping版本。然后正常情況我們需要做一系列上傳符號,找psid,獲取各種環境信息的操作給simperf,不過這個simpleperf下面有個快捷的app_profiler.py,它幫我們做好了,我們先python app_profilor.py執行這個py文件就好了。這個過程可能很慢,尤其是上傳調試符號,它會代替手機上目錄里面的so,所以對於一個手機的一次app安裝,這個操作python腳本只要執行一次就好,不執行的話可能結果里面找不到符號信息。
等這個執行好了,我們先找到這個程序的pid,利用adb shell里面的ps命令能拿到
這時我們就可以進行一次采集,比較常見的采集指令是
Adb shell run-as com.xxx.xxx
./simpleperf record -e cpu-clock:u --duration 5 –p pid --symfs .
采集好后,我們可以通過simpleperf report指令來查看結果。
最簡單的指令是./simple report –pids pid 通過這個指令可以看到這個進程里面所有線程的各個函數在這段采集時間的cpu占用百分比。如圖:
可以看到這個看上去比較亂,我們想逐個線程,並且按照一定排序來看,所以可以先顯示各個線程的
使用 ./simpleperf report --pids pid --sort tid,comm可以得到
這樣我們就可以先一眼看出主要的幾個線程的總的開銷,有UE開發經驗的同學肯定一眼就能認出這些線程,其實這里的thread-1884就是game線程了,然后我們再一點點的看每個線程就好了,我們使用./simpleperf report --pids pid –tids 1206 –g來打印rhi線程上的cpu占用,-g表示打印調用關系,我們可以得到
可以看到很清晰rhi線程上的函數開銷,這個百分比是占整個rhi線程的,不是占整個進程的,配合stat unit這樣的指令,如果我們知道rhi線程的時間,就能得到每幀某個函數的執行時間,因為rhi線程是api的提交線程,所以排名靠前的除了cp內存就是一些cmdbuff的執行函數了。
Android SDK的systrace
上面的simpleperf是個對於所有android系統不用root不用特殊工具就能得到的一種通用的函數開銷分析,在android sdk下有個systrace,可以得到除cpu函數占用外的另外一些信息,包括比較有用的cpu-gpu trace,線程的工作狀況等,也可以用來代替studio里面的線程工作查看功能。具體用法是,首先它的完整文檔可以參考
https://developer.android.com/studio/profile/systrace/command-line。
我們進入android sdk的platform-tools下面的systrace文件夾下面,Systrace主要利用了
里面的systrace.py這個命令腳本,采集一段trace,並保存成一個html文件,用來查看。常用的用法是:
python systrace.py –t 5 –a appname -o mynewtrace.html gfx view sm sched idle load
這里面表示做一次5秒的systrace,將其輸出到mynewtrace.html,然后后面是這次trace要采集的內容,具體能采集哪些內容可以使用python systrace.py --list-categories來得到。我們采集后就會生成這個html文件。
下面是查看,很多軟件可以查看trace文件,簡單的方法是打開chrome瀏覽器,輸入chrome://tracing,就能打開這個trace查看工具,然后load加載你的html文件,就可以看到這個trace圖形結果了。如圖
我們去聚焦一些有用的東西:
比如觀察cpu的trace,可以看到每個核上正在執行的線程執行的任務
又比如我們觀察下面幾行,就可以判斷當前cpu還是gpu的瓶頸。我們看SurfaceView即可以認為是GPU的繪制時間,大約10ms之內,而最下面RenderThread2上的eglswapbuf是cpu給gpu每幀最后做提交的截止,兩次eglswapbuffer直接的間隔高達53ms,說明當前是明顯的cpu瓶頸。
Lua層的函數瓶頸分析
前面我們一直在討論C++這層的瓶頸,大部分手游可能會在c++上使用lua開發,上面的工具都不直接支持對Lua的熱點函數分析,只能得到lua虛擬機的執行時間,我們就需要給lua層提供一種分析方法。
我們可以利用Lua的Debug庫,Lua虛擬機自帶了一個Debug庫,文檔可參考https://www.lua.org/pil/23.html,用它可以獲取到豐富的lua層的profile信息,最關鍵的是要為lua設置一個鈎子,即debug.sethook,我們勾住每一次函數的call和return,即使用”cr”選項,然后在鈎子事件中,我們又可以通過debug.getinfo獲得當前勾住的函數信息,我們既然已經能夠知道每次函數的調用和返回時機,剩下的工作就是寫一些統計性的代碼了。
卡頓問題
在最前面我們說低幀率和卡頓是兩種性質的問題,找到卡頓問題一般只能使用埋點的方式,即基於UE的stat系統,觀察stat的曲線,找到每個峰值。但是問題是為了發現某個位置的卡頓,這些點應該埋在哪里?畢竟UE默認的stat為我們埋的點並不能覆蓋所有地方。
我們一般可以基於UE的主線邏輯去不斷的做二分(或N分):
UE雖然是一個復雜的多線程工作的系統,但是其GameThread是控制分配其他所有線程的,所以理論上所有線程的卡頓最終都能被反應到GameThread上,而RenderThread和RHI thread是另外兩個比較容易出瓶頸的大線程,所以一般上我們能夠在這三個大線程上埋好點就可以了。
GameThread:GameThead的每幀的邏輯tick的主流程在FEngineLoop::Tick里面,我們可有通過不斷的對這個函數用scopecounter細分埋點來定位卡頓的來源。
RenderThread:RenderThread是一個命令隊列,由GameThread充填,只要這個隊列里有命令它就會持續執行,UE使用一些統一的宏去把命令加入隊列,包括ENQUEUE_UNIQUE_RENDER_COMMAND(TypeName,…)這些宏等,我們很自然的能夠想到只要在這些宏里面執行指令的時候加入一個scopecounter就可以了,就能先統計到每個渲染指令的大入口的開銷,其實ue已經這樣做了,它會為每個渲染指令在STATGROUP_RenderThreadCommands這個組下面生成一個叫做TypeName的stat。當我們找到了那個具體的RenderThread的卡頓點的時候,可以自己進入這個命令的執行函數里面進一步二分去定位。RenderThread里面通常來說比較容易成為瓶頸的大指令函數包括FMobileSceneRenderer::Render,FSlateRenderer::DrawWindow等,這些可以看做渲染的每幀主循環,要在里面進一步細分。
RHIThread:RhiThread也是一個命令隊列,由Render或者game填充並驅動指令,負責圖形API的調用。RHI命令繼承自FRHICommand,並且從ExecuteAndDestruct函數執行,所以我們其實可以在這里加入一個通用的scopecounter做統計,然后找到是哪個rhicommand是瓶頸之后再進一步在指令的excute執行函數里面細分下去。對於RHIThread的更細致的瓶頸分析在本系列之前的一篇文章中專門分析過,https://blog.csdn.net/leonwei/article/details/95527109,可以參考。
對於Render和rhi線程,他們的卡頓在stat圖表上看最終都會導致gamethread的卡頓,gamethread表現在卡在Wait for event或者SyncFrameEnd上,都表示game有可能卡在渲染任務上,wait for event是因為gamethread確實已經無事可做,而還要受taskgraph上其他依賴的線程的完成,可能是渲染線程,syncframeend則是game在執行完一幀結束的時候要檢查是不是至少上一幀的rhi執行完畢。
由於game是render和rhi的源驅動,所以通常我們在確定render和rhi卡頓的時候需要進一步追溯到是game的哪一步邏輯導致的render和rhi的卡,即”第一現場”,這里面需要排除一些多線程的因素,一種方法是我們強制單線程,即使用”-onethread”來啟動,但是這種設置可能會很卡或者運行不正常,另一種是在多線程下配合各種強制同步方法,包括:
調用FlushRenderingCommands在gamethread強行等待當前所有renderthread的指令以及rhithread中的指令全執行完,相當於一次完整的對渲染線程的強制同步,
調用GRHICommandList.GetImmediateCommandList().ImmediateFlush()則是只強制將rhithread的指令執行完畢,相當於只強制同步rhi線程
調用 GRHICommandList.GetImmediateCommandList().BlockUntilGPUIdle()則會強制把當前的的所有rhi中指令執行完畢,並且把commandbuffer發送給gpu,並且等待gpu執行完成,相當於一個強制同步到GPU的過程。
我們可以通過在某些邏輯處應用這些同步接口來在局部模擬類似單線程的情形來定位渲染上的”第一現場”。
除了Render和RHI之外,game線程在工作的時候會派發很多工作線程出去,這些對game的繼續推進有前置依賴的任務如果沒有執行完,也會導致gamethread表現的卡頓,但是其實是卡在了某個其他任務線程上,game會表現在卡在wait for event上,這時候第一要去查看其他的thread的工作情況,看看是否某個game等待的工作線程做的太久,另一種情況就是沒有找到哪個線程工作的很久,大家都在wait,這時候要分析這個包含這個wait event的函數的邏輯,說明沒有哪個線程在滿載運行,可能因為:
邏輯設計的不合理,線程間互相等待
等待IO
等待了某個需要被延時觸發的事件
等待某個昂貴的操作,但是這個操作有又被不合理的大量分幀,所以看上去在沒幀內沒有哪個線程工作飽滿,但是就是在等。
總之這種沒有明顯特征的wait要具體分析wait處的邏輯,另外要理解ue的taskgraph,asynctask等系統才會有更大幫助。
Stat Hitches
除了基於stat系統埋點之外,UE還提供stat hitches這套指令。Stat埋點的方法通常需要我們去錄很長一段數據,可能一些卡頓不是容易出現的,錄一段很長的stat數據打開也不方便。Stat Hitches這套指令是動態的去發現當前某一陣是否為卡頓幀(其實它是設置了一個閾值),然后選擇將其顯示出來,或者保存當前幀前后的stat數據。一般用法是先設置 t.HitchFrameTimeThreshold 定義卡頓的幀時間閾值,然后用指令stat hitches可以直觀看到掉幀時的屏幕顯示,用指令stat DumpHitches則可以將掉幀時候的stat數據保存下來及輸出到控制台。
對於UE程序有很多種方法分析幀率瓶頸及卡頓的性能問題,解決問題的前提是找到問題,而找到問題的前提是找到或者制作合適的工具來捕捉到問題。作為引擎和游戲的優化開發人員,無論是什么機型,只要安裝我們的版本,我們就可以從上面的方法中至少找到一個有效的方法定位問題,才能做到不慌,保證問題得到解決。