如何高效準確詳細的對性能進行剖析?騰訊遊戲學院專家Leonn將概括總結在UE下對每一性能指標的剖析方法,本文重點講解如何應對CPU幀率瓶頸和卡頓?html
CPU上幀率低和卡頓是性能優化中最易出現的一部分,尤爲對於手遊,提到卡,就大機率是在CPU上出現的問題,CPU上的卡頓通常是卡邏輯或是卡渲染,本篇將詳細系統的介紹基於UE的手遊對CPU瓶頸的剖析方法。python
低幀率和卡頓android
首先低幀率和卡頓是兩種徹底不一樣的瓶頸類型,雖然歸根到底都是某個函數執行的過慢引發的,可是定位和解決方法並不同。低幀率瓶頸是須要統計一段時間內CPU把更多的時鐘耗費在了哪些函數上,或統計一段時間內各個函數佔用的CPU時間百分比,找到百分比高的將其優化,就會使幀率獲得總體的提升。卡頓則是在一幀的一次運行內某段代碼的運行產生了比平均狀況明顯的長時間,須要定義這段代碼的起始點,分別進行計時,而後在連續的統計數據中找到峯值。簡單來講幀率瓶頸是統計平均的CPU佔用,而卡頓是找峯值。ios
低幀率瓶頸—平均CPU佔用c++
對於UE程序,咱們一般有下面一些方法去找到函數的平均CPU佔用。一種是基於UE內置的stat機制,另外一類是基於各類平臺相關工具。
UE的stat機制chrome
UE本身的stat機制是一種基於埋點的機制,即經過在一段邏輯先後顯示的增長標籤來錄得這段時間這個標籤內邏輯的運行時間。而後利用UE的frontend可視化全部打了標籤的函數的運行時間曲線。這個基於埋點的機制的好處是:不只能夠看到平均CPU佔用,也能看到峯值。缺點就是須要人工打標籤,你須要不斷的細分一些標籤去找到瓶頸。shell
Stat的代碼機制是這樣運做的:api
首先UE有不少種類型的stat,測試CPU運行時間的stat叫作cycle stat。典型的使用分三步:xcode
第一步:每一個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(FNameInStatName, EStatOperation::TypeInStatOperation )這個機制去操縱某個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:androidsdutio工程路徑,這個在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 –pidspid經過這個指令能夠看到這個進程裏面全部線程的各個函數在這段採集時間的CPU佔用百分比。如圖:
能夠看到這個看上去比較亂,咱們想逐個線程,而且按照必定排序來看,因此能夠先顯示各個線程的。
使用 ./simpleperfreport --pidspid--sorttid,comm能夠獲得。
這樣咱們就能夠先一眼看出主要的幾個線程的總的開銷,有UE開發經驗的同窗確定一眼就能認出這些線程,其實這裏的thread-1884就是game線程了,而後咱們再一點點的看每一個線程就行了,咱們使用./simpleperfreport --pidspid–tids 1206 –g來打印RHI線程上的CPU佔用,-g表示打印調用關係,咱們能夠獲得。
能夠看到很清晰rhi線程上的函數開銷,這個百分比是佔整個rhi線程的,不是佔整個進程的,配合stat unit這樣的指令,若是咱們知道rhi線程的時間,就能獲得每幀某個函數的執行時間,由於rhi線程是api的提交線程,因此排名靠前的除了cpu內存就是一些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 smsched 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執行函數裏面細分下去。
對於Render和RHI線程,他們的卡頓在stat圖表上看最終都會致使gamethread的卡頓,gamethread表如今卡在Waitforevent或者SyncFrameEnd上,都表示game有可能卡在渲染任務上,waitforevent是由於gamethread確實已經無事可作,而還要受taskgraph上其餘依賴的線程的完成,多是渲染線程,syncframeend則是game在執行完一幀結束的時候要檢查是否是至少上一幀的rhi執行完畢。
因爲game是render和rhi的源驅動,因此一般咱們在肯定render和rhi卡頓的時候須要進一步追溯到是game的哪一步邏輯致使的render和rhi的卡,即」第一現場」,這裏面須要排除一些多線程的因素,一種方法是咱們強制單線程,即便用」-onethread」來啓動,可是這種設置可能會很卡或者運行不正常,另外一種是在多線程下配合各類強制同步方法,包括:
l 調用FlushRenderingCommands在gamethread強行等待當前全部renderthread的指令以及rhithread中的指令全執行完,至關於一次完整的對渲染線程的強制同步。
l 調用GRHICommandList.GetImmediateCommandList().ImmediateFlush()則是隻強制將rhithread的指令執行完畢,至關於只強制同步rhi線程。
l 調用GRHICommandList.GetImmediateCommandList().BlockUntilGPUIdle()則會強制把當前的的全部rhi中指令執行完畢,而且把commandbuffer發送給gpu,而且等待gpu執行完成,至關於一個強制同步到GPU的過程。
咱們能夠經過在某些邏輯處應用這些同步接口來在局部模擬相似單線程的情形來定位渲染上的「第一現場」。
除了Render和RHI以外,game線程在工做的時候會派發不少工做線程出去,這些對game的繼續推動有前置依賴的任務若是沒有執行完,也會致使gamethread表現的卡頓,可是實際上是卡在了某個其餘任務線程上,game會表如今卡在wait for event上,這時候第一要去查看其餘的thread的工做狀況,看看是否某個game等待的工做線程作的過久,另外一種狀況就是沒有找到哪一個線程工做的好久,你們都在wait,這時候要分析這個包含這個wait event的函數的邏輯,說明沒有哪一個線程在滿載運行,可能由於:
l 邏輯設計的不合理,線程間互相等待。
l 等待IO。
l 等待了某個須要被延時觸發的事件。
l 等待某個昂貴的操做,可是這個操做有又被不合理的大量分幀,因此看上去在沒幀內沒有哪一個線程工做飽滿,可是就是在等。
總之這種沒有明顯特徵的wait要具體分析wait處的邏輯,另外要理解UE的taskgraph,asynctask等系統纔會有更大幫助。
Stat Hitches
除了基於stat系統埋點以外,UE還提供stat hitches這套指令。Stat埋點的方法一般須要咱們去錄很長一段數據,可能一些卡頓不是容易出現的,錄一段很長的stat數據打開也不方便。Stat Hitches這套指令是動態的去發現當前某一陣是否爲卡頓幀(其實它是設置了一個閾值),而後選擇將其顯示出來,或者保存當前幀先後的stat數據。
通常用法是先設置 t.HitchFrameTimeThreshold定義卡頓的幀時間閾值,而後用指令stat hitches能夠直觀看到掉幀時的屏幕顯示,用指令stat DumpHitches則能夠將掉幀時候的stat數據保存下來及輸出到控制檯。 對於UE程序有不少種方法分析幀率瓶頸及卡頓的性能問題,解決問題的前提是找到問題,而找到問題的前提是找到或者製做合適的工具來捕捉到問題。做爲引擎和遊戲的優化開發人員,不管是什麼機型,只要安裝咱們的版本,咱們就能夠找到一個有效的方法定位問題,才能作到不慌,保證問題獲得解決。