如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

遊資網發表於2019-09-19
導語:如何高效準確詳細的對效能進行剖析?騰訊遊戲學院專家Leonn將歸納總結在UE下對每一效能指標的剖析方法,本文重點講解CPU幀率瓶頸和卡頓。

系列回顧:UE高階效能剖析技術之RHI

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(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看到的具體內容可以如下:

如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

如何從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佔用時間比,執行次數,等等,如圖:

如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

還可以看到具體的每個執行緒每個函式執行的時序,如圖:

如何應對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佔用百分比。如圖:

如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

可以看到這個看上去比較亂,我們想逐個執行緒,並且按照一定排序來看,所以可以先顯示各個執行緒的。

使用 ./simpleperfreport --pidspid--sorttid,comm可以得到。

如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

這樣我們就可以先一眼看出主要的幾個執行緒的總的開銷,有UE開發經驗的同學肯定一眼就能認出這些執行緒,其實這裡的thread-1884就是game執行緒了,然後我們再一點點的看每個執行緒就好了,我們使用./simpleperfreport --pidspid–tids 1206 –g來列印RHI執行緒上的CPU佔用,-g表示列印呼叫關係,我們可以得到。

如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

可以看到很清晰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幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

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

如何應對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:rawWindow等,這些可以看做渲染的每幀主迴圈,要在裡面進一步細分。

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執行完畢。

如何應對CPU幀率瓶頸和卡頓?騰訊遊戲學院專家帶你剖析

由於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程式有很多種方法分析幀率瓶頸及卡頓的效能問題,解決問題的前提是找到問題,而找到問題的前提是找到或者製作合適的工具來捕捉到問題。作為引擎和遊戲的優化開發人員,無論是什麼機型,只要安裝我們的版本,我們就可以找到一個有效的方法定位問題,才能做到不慌,保證問題得到解決。

來源:騰訊GWB遊戲無界
原地址:https://mp.weixin.qq.com/s/CMq-SS5a-T2JXjMKjTNNUA

相關文章