UE高級性能剖析技術(2) -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就是之前定義的groupstatname。這個宏其實是調用一個更加通用類型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 groupstat 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組下的叫做counternamestatid,並且立即啓動一個它的scopecyclecounter,這也是一個在代碼裏快捷加cycle 統計的方法。

DECLARE_STATS_GROUP_VERBOSE聲明一個默認不被enable的組

CONDITIONAL_SCOPE_CYCLE_COUNTER(Stat,bCondition)只有在bConditiontrue的情況下才統計

此外可以定義上面除了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 cycleMemoryRegion-是否是對memory的統計,如果是統計的mem類型是什麼。

2.定義一個通常是全局的FThreadSafeStaticStat<FStat_##Stat> StatPtr_##Stat來方便的獲取某個stat 名字的statid計數器類型

3.使用FThreadStats::AddMessage(FName InStatName, EStatOperation::Type InStatOperation )這個機制去操縱某個stat計數器的值。InStatName就是這裏的stat的名字,InStatOperation包括的操作包括:CycleScopeStartCycleScopeEnd -將這段時間內的cpu 時間ms記錄下來加到計數器裏, Set-直接設置計數器的值,Clear-清空計數器的值,Add-增加計數器的值,Subtract-減少計數器的值。

所以上面的各種宏只是對上面這三個步驟的各種簡化封裝。

 

Stat系統給我們提供了一個基於埋點的統計函數cpu時間的機制,它很強大,我們可以通過stat group去動態看到這些時間(那些默認enable的),也可以通過ueprofilor去看各個計數器的時間曲線。但是很多時候當我們不能預感到哪裏會有瓶頸的時候,即不知道在哪裏埋點的時候,就需要更通用一些的機制。就依託一些平臺的工具了。

 

平臺工具

 

XCodecounter

counterxcodeinstrument裏面的一個工具,他可以記錄cpu上每個線程在一段時間內的各個函數的cpu佔用時間比,對於ios系統來說,這個是衡量cpu幀率瓶頸的golden ruleCounter看到的具體內容可以如下:

如何從Counter來推測出每個函數的每幀具體時間開銷呢?Counter給的是一個cpu的時間佔比,我們可以先看到具體gamethread佔用cpu的時間比r,然後從uestat unit得到gamethread的每幀時間t,然後對於一個具體函數它的cpu時間佔比如果是b,那麼這個函數平均每幀的執行時間就是t*b/r.

 

Android Studioprofiler

Android Studio3.0以上的profiler很強大,如果device8.0以上的android系統,那麼將可以用profilor capture一段時間的c++android trace。然後可以從圖表中看到當前每個thread中每個函數的cpu佔用時間比,執行次數,等等,如圖

 

還可以看到具體的每個線程每個函數執行的時序,如圖

通過這個profiler不僅可以像xcodecounter一樣獲取所有c++函數的每幀執行時間,找到熱點函數,我們還可以從thead的執行時許上直觀看到多線程之間的函數執行關係,多線程的執行狀態是否合理,比如看到game線程在某個地方需要等待很久某個work線程完成,那麼可以嘗試把work再分並行,或者調整某些無關的事情提前,讓game等這個work的同時在做一些別的工作,不要乾等。

 

Android NDK simpleperf

對於低版本無法使用android studio profiler調試的可以依賴Android sdk裏面的另外兩個有用的工具,一個是NDKsimpleperf,它可以調試獲取c++層每個函數的cpu佔用百分比,除了需要用命令行並且輸出的格式沒那麼好看之外,同studioprofilor能拿到的結果是差不多的。

Simpleperf的完全使用文檔在https://developer.android.com/ndk/guides/simpleperf,其實主要分爲兩步,第一步是用simperperf record命令去採集數據,第二步是用simpleperf report命令去輸出數據。

一種比較簡單的使用方法是這樣的,首先連接手機,運行程序,確保在usb調試狀態下,首先進入ndksimpleperf目錄下,打開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 SDKsystrace

上面的simpleperf是個對於所有android系統不用root不用特殊工具就能得到的一種通用的函數開銷分析,在android sdk下有個systrace,可以得到除cpu函數佔用外的另外一些信息,包括比較有用的cpu-gpu trace,線程的工作狀況等,也可以用來代替studio裏面的線程工作查看功能。具體用法是,首先它的完整文檔可以參考

https://developer.android.com/studio/profile/systrace/command-line

我們進入android sdkplatform-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圖形結果了。如圖

我們去聚焦一些有用的東西:

比如觀察cputrace,可以看到每個核上正在執行的線程執行的任務

又比如我們觀察下面幾行,就可以判斷當前cpu還是gpu的瓶頸。我們看SurfaceView即可以認爲是GPU的繪製時間,大約10ms之內,而最下面RenderThread2上的eglswapbufcpugpu每幀最後做提交的截止,兩次eglswapbuffer直接的間隔高達53ms,說明當前是明顯的cpu瓶頸。

 

Lua層的函數瓶頸分析

前面我們一直在討論C++這層的瓶頸,大部分手遊可能會在c++上使用lua開發,上面的工具都不直接支持對Lua的熱點函數分析,只能得到lua虛擬機的執行時間,我們就需要給lua層提供一種分析方法。

我們可以利用LuaDebug庫,Lua虛擬機自帶了一個Debug庫,文檔可參考https://www.lua.org/pil/23.html用它可以獲取到豐富的lua層的profile信息,最關鍵的是要爲lua設置一個鉤子,即debug.sethook,我們勾住每一次函數的callreturn,即使用”cr”選項,然後在鉤子事件中,我們又可以通過debug.getinfo獲得當前勾住的函數信息,我們既然已經能夠知道每次函數的調用和返回時機,剩下的工作就是寫一些統計性的代碼了。

 

卡頓問題

在最前面我們說低幀率和卡頓是兩種性質的問題,找到卡頓問題一般只能使用埋點的方式,即基於UEstat系統,觀察stat的曲線,找到每個峯值。但是問題是爲了發現某個位置的卡頓,這些點應該埋在哪裏?畢竟UE默認的stat爲我們埋的點並不能覆蓋所有地方。

我們一般可以基於UE的主線邏輯去不斷的做二分(或N分):

UE雖然是一個複雜的多線程工作的系統,但是其GameThread是控制分配其他所有線程的,所以理論上所有線程的卡頓最終都能被反應到GameThread上,而RenderThreadRHI thread是另外兩個比較容易出瓶頸的大線程,所以一般上我們能夠在這三個大線程上埋好點就可以了。

GameThreadGameThead的每幀的邏輯tick的主流程在FEngineLoop::Tick裏面,我們可有通過不斷的對這個函數用scopecounter細分埋點來定位卡頓的來源。

RenderThreadRenderThread是一個命令隊列,由GameThread充填,只要這個隊列裏有命令它就會持續執行,UE使用一些統一的宏去把命令加入隊列,包括ENQUEUE_UNIQUE_RENDER_COMMANDTypeName)這些宏等,我們很自然的能夠想到只要在這些宏裏面執行指令的時候加入一個scopecounter就可以了,就能先統計到每個渲染指令的大入口的開銷,其實ue已經這樣做了,它會爲每個渲染指令在STATGROUP_RenderThreadCommands這個組下面生成一個叫做TypeNamestat。當我們找到了那個具體的RenderThread的卡頓點的時候,可以自己進入這個命令的執行函數裏面進一步二分去定位。RenderThread裏面通常來說比較容易成爲瓶頸的大指令函數包括FMobileSceneRenderer::RenderFSlateRenderer::DrawWindow等,這些可以看做渲染的每幀主循環,要在裏面進一步細分。

RHIThreadRhiThread也是一個命令隊列,由Render或者game填充並驅動指令,負責圖形API的調用。RHI命令繼承自FRHICommand,並且從ExecuteAndDestruct函數執行,所以我們其實可以在這裏加入一個通用的scopecounter做統計,然後找到是哪個rhicommand是瓶頸之後再進一步在指令的excute執行函數裏面細分下去。對於RHIThread的更細緻的瓶頸分析在本系列之前的一篇文章中專門分析過,https://blog.csdn.net/leonwei/article/details/95527109可以參考。

 

對於Renderrhi線程,他們的卡頓在stat圖表上看最終都會導致gamethread的卡頓,gamethread表現在卡在Wait for event或者SyncFrameEnd上,都表示game有可能卡在渲染任務上,wait for event是因爲gamethread確實已經無事可做,而還要受taskgraph上其他依賴的線程的完成,可能是渲染線程,syncframeend則是game在執行完一幀結束的時候要檢查是不是至少上一幀的rhi執行完畢。

由於gamerenderrhi的源驅動,所以通常我們在確定renderrhi卡頓的時候需要進一步追溯到是game的哪一步邏輯導致的renderrhi的卡,即第一現場”,這裏面需要排除一些多線程的因素,一種方法是我們強制單線程,即使用”-onethread來啓動,但是這種設置可能會很卡或者運行不正常,另一種是在多線程下配合各種強制同步方法,包括:

  1. 調用FlushRenderingCommandsgamethread強行等待當前所有renderthread的指令以及rhithread中的指令全執行完,相當於一次完整的對渲染線程的強制同步,
  2. 調用GRHICommandList.GetImmediateCommandList().ImmediateFlush()則是隻強制將rhithread的指令執行完畢,相當於只強制同步rhi線程
  3. 調用 GRHICommandList.GetImmediateCommandList().BlockUntilGPUIdle()則會強制把當前的的所有rhi中指令執行完畢,並且把commandbuffer發送給gpu,並且等待gpu執行完成,相當於一個強制同步到GPU的過程。

我們可以通過在某些邏輯處應用這些同步接口來在局部模擬類似單線程的情形來定位渲染上的第一現場

 

除了RenderRHI之外,game線程在工作的時候會派發很多工作線程出去,這些對game的繼續推進有前置依賴的任務如果沒有執行完,也會導致gamethread表現的卡頓,但是其實是卡在了某個其他任務線程上,game會表現在卡在wait for event上,這時候第一要去查看其他的thread的工作情況,看看是否某個game等待的工作線程做的太久,另一種情況就是沒有找到哪個線程工作的很久,大家都在wait,這時候要分析這個包含這個wait event的函數的邏輯,說明沒有哪個線程在滿載運行,可能因爲:

  1. 邏輯設計的不合理,線程間互相等待
  2. 等待IO
  3. 等待了某個需要被延時觸發的事件
  4. 等待某個昂貴的操作,但是這個操作有又被不合理的大量分幀,所以看上去在沒幀內沒有哪個線程工作飽滿,但是就是在等。

總之這種沒有明顯特徵的wait要具體分析wait處的邏輯,另外要理解uetaskgraphasynctask等系統纔會有更大幫助。

 

Stat Hitches

除了基於stat系統埋點之外,UE還提供stat hitches這套指令。Stat埋點的方法通常需要我們去錄很長一段數據,可能一些卡頓不是容易出現的,錄一段很長的stat數據打開也不方便。Stat Hitches這套指令是動態的去發現當前某一陣是否爲卡頓幀(其實它是設置了一個閾值),然後選擇將其顯示出來,或者保存當前幀前後的stat數據。一般用法是先設置 t.HitchFrameTimeThreshold 定義卡頓的幀時間閾值,然後用指令stat hitches可以直觀看到掉幀時的屏幕顯示,用指令stat DumpHitches則可以將掉幀時候的stat數據保存下來及輸出到控制檯。

 

對於UE程序有很多種方法分析幀率瓶頸及卡頓的性能問題,解決問題的前提是找到問題,而找到問題的前提是找到或者製作合適的工具來捕捉到問題。作爲引擎和遊戲的優化開發人員,無論是什麼機型,只要安裝我們的版本,我們就可以從上面的方法中至少找到一個有效的方法定位問題,才能做到不慌,保證問題得到解決。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章