第2章. 自動化每日建置的實機效能分析

Minhyuk Kim

株式會社 CyberAgent SGE 核心技術本部

實機的效能分析,大多只在有人認為需要時才會進行。發布前的衝刺階段,有人接上 Profiler,發現尖峰或記憶體增加,再去找出造成問題的 commit。在這種流程下,追查「從何時開始變差」的範圍往往是好幾週的變更。本章介紹在每次每日建置之後,自動在實機上執行測量的配置。

整體配置分為三個部分:出借實機的裝置農場(device farm)、內嵌於應用程式並接受外部操作的遠端操作伺服器,以及將測量資料傳送至伺服器的測量 SDK。從裝置農場遠端借用實機,透過遠端操作依序巡覽遊戲畫面,同時以測量 SDK 傳送記憶體快照與 Profiler 擷取。將這套流程作為每日建置的後續工作掛上 CI,每次建置完成後,就會累積以 commit 為單位的測量資料。累積的資料可從命令列取出,也能作為讓 AI 代理讀取變更並進一步提出修正方案的入口。

本章說明在公司內部專案中運作的每日巡覽工作流程,以及為達成此流程所做的各項巧思。撰寫本文時僅支援 Android 裝置,iOS 的支援預計日後實作。

2.1. 在建置之後銜接測量工作

管線依 圖 2.1 的順序進行。裝置的取得、應用程式的操作、測量資料的傳送,分別由不同工具負責。即使將其中任何一項替換為其他工具,這樣的分工仍可沿用。

每日測量管線的各階段

圖 2.1: 每日測量管線的各階段

每日建置本身不需要修改。每日建置中不需要另外內建巡覽用的流程,也不需要預先設定測量用程式碼;正因為不放置這類以測量為前提的程式碼,即使在內部結構劇烈變動的開發階段,巡覽也不會損壞,得以維持穩定性與彈性。其做法將在下文詳細說明。

2.2. 由 CI 接收建置完成事件後開始巡覽

每日建置完成後,另一個工作流程會訂閱建置完成的觸發事件,只在建置成功時啟動巡覽。此時傳遞給後續工作流程的,是建置 APK 的 run ID。

啟動的工作流程根據 run ID 找出建置的 artifact 並下載。接著啟動負責巡覽的 LLM 代理。撰寫本文時,模型使用 Claude Opus 5.5,決定推理深度的 effort 設為 medium。

2.3. Unity 的測量在應用程式內部進行

本章要自動化的是 Unity 端的測量。目的是每天以相同方式取得並比較 Unity 內部的明細,例如哪些資產佔用記憶體、哪些 Profiler 標記(marker)負載較重。

從裝置外部可用的測量手段中,當然也有許多實用的工具。iOS 可用 Instruments 的 Time Profiler 以呼叫堆疊為單位追蹤瓶頸。Android 也能透過 simpleperf,或使用它的 Android Studio CPU Profiler,取樣到 IL2CPP 產生的原生函式層級。將這些工具運用於自動化也相當值得考慮,但不在本次範圍內。

Unity 的明細通常是將 Unity 編輯器的 Profiler 或 Memory Profiler 連接實機來取得。然而,一旦在 CI 的巡覽中加入編輯器,需要考慮的事情就會大幅增加。每次在 runner 上開啟專案都得等待 Library 資料夾的匯入,每台 runner 也都需要 Unity 授權。此外,除了編輯器與實機的連線,還必須準備讓代理操作編輯器的途徑(例如 Unity MCP),配置會變得複雜。

因此,我們決定在 CI 上不使用編輯器,只使用執行階段 API,讓測量在應用程式內部完成。

2.4. 裝置從裝置農場借用

裝置農場採用可從瀏覽器操作實機的 STF 平台 *1。不過,本章使用的並非透過畫面操作裝置農場,而僅是透過 API 取得裝置;預約裝置後會回傳遠端的 adb 端點,CI runner 可將其視同 USB 連線來使用。從預約到安裝 APK 整合成一支腳本,CI 只需一個步驟即可呼叫。

若只是為了巡覽,有一台 Android 10 以上的空閒裝置即可;但若要逐日比較測量結果,裝置每天不同就無法比較。測量用途應固定機種,若當天沒有空閒裝置則跳過,這樣才不會累積無法比較的資料。

2.5. 以應用程式內遠端操作巡覽畫面

應用程式的操作使用內嵌於應用程式的遠端操作伺服器。應用程式啟動後只在裝置內部等待指令,CI runner 透過 adb 連線並傳送指令。除了確認應用程式是否運作中、取得場景上的物件組成、擷取螢幕截圖、取得記錄等固定指令之外,也能在應用程式內執行以 JavaScript 撰寫的任意處理。

這些 JavaScript 由 Jint *2 解譯,可直接存取 C# 的型別。依名稱找到 UI 按鈕並按下、讀取導覽的目前節點、啟動測量 SDK,全都能透過同一條途徑完成。這接近於可在實機上即時撰寫並試跑 C# 程式碼的環境,測量 SDK 的 API 也能不重新建置就從外部呼叫。每次變更閾值或目標畫面時,不需要重新製作 APK。

要每天比較測量結果,就必須每天以相同順序經過相同畫面。固定座標的點擊會因裝置解析度或版面配置改變而偏移,因此改為依名稱取得執行階段的 UI 物件,直接觸發 UI 框架的事件。目前位於哪個畫面,則透過讀取導覽狀態來判定。若專案端已有畫面切換圖的定義,可直接作為巡覽計畫的輸入。

2.6. 上傳快照與擷取兩種資料

測量資料的傳送使用筆者所屬組織開發的測量平台。內嵌於應用程式的 SDK 將測量資料傳送至伺服器,由伺服器端累積,並在儀表板上提供逐日比較,以及在惡化時發出警示。

SDK 能傳送的不只是 Unity 的記憶體快照與 Profiler 擷取。CPU 時間明細與繪製呼叫數等繪製負載、程式碼中任意區間的處理時間、應用程式的啟動時間、以固定間隔取得的記憶體用量變化等也都能傳送,納入巡覽後同樣可進行定點觀測。本章僅聚焦於其中 表 2.1 的兩種來說明。

表 2.1: 每日巡覽傳送的測量資料

種類取得時機前提傳送量的考量
記憶體快照到達各畫面並穩定之後Development Build每個畫面一份,全部傳送
Profiler 擷取從巡覽開始到結束一份啟用 Profiler 的建置只傳送超過閾值的影格

2.6.1. 快照按畫面逐一取得

記憶體快照與 Unity 的 Memory Profiler 格式相同,依 Unity 的機制只能在 Development Build 中取得 *3。每日建置的巡覽用 APK 必須是 Development Build,無法將發布設定的建置挪作測量之用。

快照在巡覽到達畫面且載入穩定後取得一份。名稱中包含畫面名稱與 commit hash,以便日後將同一畫面不同日期的資料並列比較。

2.6.2. 擷取每次巡覽取一份,並在裝置上篩選

Profiler 擷取是由 SDK 在裝置上逐步讀取 Unity 寫出的原始 Profiler 記錄,只將符合條件的影格寫入另一個檔案並上傳。不直接保留所有影格,是因為 Profiler 不適合長時間記錄。編輯器的 Profiler 視窗最多只能保留 2,000 個影格,在 60fps 下約為 30 秒多。由於會從最舊的影格開始捨棄,即使連接編輯器記錄數十分鐘的巡覽,留下的也只有最後 30 秒多。若每次都寫出成檔案,雖然沒有影格數的上限,檔案卻會膨脹到每次巡覽數十 GB。無論是作為 CI 的 artifact 保留,還是每天累積,都太大了。

擷取在巡覽開始時啟動,在結束時一併停止並上傳。開始時決定的是保留哪些影格的閾值,以影格時間與 GC 配置量兩項指定。停止時會先處理完原始記錄中尚未讀取的部分才完成。由於篩選在測量期間同時並行進行,即使像 Deep Profiling 這樣寫出速度很快的情況,未讀取的部分也幾乎不會累積。儘管如此,為避免在停止完成前就歸還裝置,停止會在巡覽的截止時間前預留充裕時間開始。

2.6.3. 只讀取影格邊界,GC 以時刻比對

原始記錄是將各執行緒的緩衝區以區塊為單位依序追加而成。要知道哪個區塊屬於哪個影格,必須找出影格邊界。線索在於 Profiler 的寫出方式。Profiler 在寫入影格邊界的前後會切換執行緒的緩衝區,因此影格邊界會以只有十幾個位元組的獨立區塊送達。於是篩選執行緒只開啟此長度以下的區塊,確認是否為影格邊界。比這更長的區塊則不讀取內容,一律視為屬於該執行緒目前的影格。當主執行緒收到下一個影格邊界時,影格的長度就確定了,此時判定是否保留,並只將保留影格的區塊依原始順序寫出。像取樣器名稱、執行緒名稱這類不屬於任何影格的定義區塊則全部寫出,因此輸出可直接在 Unity 的 Profiler 視窗中開啟。

GC 配置量無法從原始記錄取得。原始記錄的影格統計中雖然也有 GC 欄位,但從 Unity 2022.3 到 6000.5,確認過的所有擷取都是 0。因此在測量期間,主執行緒每個影格都以 ProfilerRecorder 讀取 GC Allocated In Frame 計數器。ProfilerRecorder 的值不帶時刻,所以在同一影格內讀取 ProfilerUnsafeUtility.Timestamp 並附加到值上。這個時刻與原始記錄中影格邊界所記錄的時刻使用相同的時鐘。篩選執行緒將某個影格開始到下一個影格開始之間記錄的值,比對為該影格的 GC 配置量。某個影格的值要到下一個影格內才能取得,因此在值送達之前會等待數個影格再判定。

在裝置上進行篩選,或許會讓人擔心這本身是否會加重測量負載。篩選在專用執行緒上進行,主執行緒只負責確認資料的增長與讀取 GC 計數器。如前所述,大多數區塊不讀取內容,因此篩選所需的處理僅限於極少部分資料。篩選執行緒與寫出 Profiler 資料的 Dispatcher 執行緒,以執行緒名稱區分並從輸出中排除。此外,篩選處理也排除在 Deep Profiling 之外。若納入其中,篩選執行緒的每一次方法呼叫都會成為取樣,寫入正在讀取的原始記錄中。不過,GC 計數器是整個程序的值,因此也包含篩選執行緒每個影格配置的數 KB。與後述的閾值相比這是相當小的量,但包含啟用 Profiler 本身的負載在內,不能說測量完全沒有額外開銷。

2.6.4. 閾值依想觀察的問題與資料量決定

筆者的巡覽目前以 300ms、2MB 為閾值。對 30fps 的遊戲而言,這約為一般影格時間的 10 倍,用意不是觀察遊玩中的細微卡頓,而是確認畫面切換或載入時發生的大幅停頓是否比昨天增加。在此設定下,整個巡覽中保留的只有極少數影格,資料量控制在可每天持續傳送的範圍內。降低閾值雖然也能撿到細微卡頓,但保留的影格增加會使檔案變大,也會更快達到後述的輸出上限。請依遊戲的影格率以及希望透過測量解決的問題,一邊觀察資料量一邊調整閾值來運作。

2.6.5. 讓長時間巡覽符合裝置的儲存容量

巡覽時間一長,原始記錄的存放位置也會成為問題。SDK 每達一定大小就切換原始記錄的寫出目的地,分割成連號的檔案,篩選執行緒則會刪除已讀取完畢的檔案。如此一來,裝置的儲存空間用量就會停在數個檔案的大小。當篩選落後、未處理的檔案累積過多時,就停止記錄。切換檔案時,Unity 會在新檔案的開頭重新寫入到目前為止出現過的所有取樣器名稱與物件名稱的定義。由於輸出會串接成單一檔案,第二個檔案之後的定義大多是重複的。因此,與前一次寫入的內容在位元組層級完全相同的定義,就從輸出中省略。輸出檔案設有 1.5GB 的上限,在下一個要保留的影格會超過上限時停止記錄。可上傳的大小最多為 2GB,因此在此之前停止,確保上限之前保留的影格一定能傳送。

擷取中也內嵌了各影格的螢幕截圖。因為只憑 Profiler 的數值,很難得知尖峰發生時位於哪個畫面、顯示了什麼。畫面以非同步 GPU 回讀取得,因此要數個影格之後才能取得影像。於是在每個影格結尾重新內嵌最後取得的影像,讓無論保留哪個影格都附有影像。從 Unity 6.6 起,Profiler 的標準功能也能連續記錄螢幕截圖,且預設已啟用截圖功能。不過拍攝固定為每 15 個影格一次,在筆者的 Android 裝置上記錄的影像只有 256×112 像素,無法閱讀 UI 文字。此外,在不支援 AsyncGPUReadback 的裝置上沒有其他選項,螢幕截圖會被靜默停用。在某天以 300ms、2MB 篩選的擷取中,保留的 71 個影格裡附有標準螢幕截圖的只有 6 個;由於與保留影格不連續的篩選方式不相容,因此持續使用 SDK 端的拍攝。

2.6.6. 中繼資料附加於所有資料

除了上傳擷取時附帶的中繼資料之外,SDK 還提供為所有測量資料附加共通資訊的機制。只要在初始化後立即註冊建置時嵌入的 commit hash、建置的 run ID、裝置型號,快照與擷取都會附上相同資訊,操作端也不必每次傳遞。測量結果能依 commit 順序排列,正是因為有這項註冊。

2.7. 也透過巡覽收集著色器的預熱資料

巡覽每天都會走過遊戲的主要畫面,因此也成為收集著色器預熱資料的場合。GraphicsStateCollection (GSC) 是一種資產,會記錄執行期間使用過的著色器變體與繪製狀態的組合,供下次之後預先預熱。

收集時不使用測量 SDK,而是從遠端操作直接呼叫 Unity 的 API。在巡覽開始時開始記錄,照常巡覽畫面,結束時停止記錄並以檔案形式寫入裝置上應用程式的儲存區域。寫出的檔案以 adb 取出至 CI runner。由於不使用編輯器或 Player 連線,與測量一樣只在 CI 內部就能完成。不過,在 IL2CPP 的建置中,程式碼剝除(code stripping)會移除未使用型別的成員,可能導致無法從遠端操作呼叫。巡覽用的建置需要在 link.xml 中保留 GraphicsStateCollection 的型別。

取出的檔案不會直接放進專案。只有在巡覽未因錯誤中止而正常結束、且檔案已完成儲存的日子,才會將其合併到相同平台與品質設定的既有資產中,移除預熱時被警告找不到的變體,然後自動建立資產更新的 PR 並自動合併。每天巡覽所經畫面的記錄,就這樣逐步累積。

話雖如此,僅靠這個方法並不能涵蓋所有必要的變體。記錄下來的只有巡覽實際經過的畫面,以及當時繪製的內容。巡覽未經過的畫面、只在特定條件下出現的特效等,都會從記錄中遺漏。關於這類遺漏,以及已預熱卻仍殘留卡頓等陷阱,請參照第 1 部第 6 章。若您是從頭依序讀到此處,可能已經看過了。

2.8. 從偵測惡化到修正 PR 都交給代理

即使每天累積資料,若沒有人每天早上開啟儀表板,也只是累積而已。這個測量平台還提供可從命令列取得資料的 CLI,本章的管線將從確認惡化到建立修正 PR 的工作,交給使用此 CLI 的代理。不是由人每次撰寫請求,而是作為測量之後的後續工序每天執行。

巡覽的測量結束後,會啟動與巡覽不同的比較用代理。此代理以 CLI 取得今天與前一天的測量資料,比較同一畫面的快照以及擷取中保留的尖峰,確認是否有惡化之處。若發現惡化,就彙整哪個畫面的哪個值變化了多少、用於比較的資料、期間內納入的 commit 範圍,登錄為待辦清單(backlog)中的 issue。若沒有惡化則不登錄任何內容。

登錄的 issue 再由另一個負責修正的代理接手。確認 issue 內容,從 commit 歷史縮小原因範圍後加入修正並建立 PR。人的參與從該 PR 的審查開始。

2.8.1. 修正的確認也以同一套機制在實機上進行

修正的確認與每日巡覽分開進行。每日巡覽是以固定順序經過固定畫面,不適合配合各 issue 想確認的畫面或條件。因此,以待辦清單上的 issue 進入修正完成(待確認)狀態為契機,啟動修正確認用的代理。此代理使用與每日巡覽相同的機制,也就是裝置農場的裝置、應用程式內遠端操作、測量 SDK,在實機上執行納入修正的建置,並與 issue 中留下的比較來源資料比對。只有在實機上確認惡化已消除,才將工單交給 QA。為確認修正是否有效,開發者不需要接觸實機。

這套確認機制不限於效能惡化,也可用於日常的缺陷修正。如前所述,遠端操作實質上等同於反射,可直接存取的處理都能當場執行,因此代理幾乎能操作並讀取遊戲中發生的一切。只要 issue 中寫有重現步驟與預期結果,就能在實機上依照步驟操作,連同遊戲內部狀態一併確認是否已修正。

2.9. 持續每天執行的運作方式

2.9.1. 巡覽代理與測量步驟的分工

本章的巡覽是將畫面切換圖與遠端操作的使用方法交給程式設計代理來執行。雖然具備可迴避無法到達的畫面、新增畫面時可重新規劃的彈性,但一次巡覽需要約 90 個回合,費用約 5 美元。像測量的開始與停止這類每次都必須相同的部分,不交由代理判斷,而是固定為可重複使用的步驟,只讓代理在指定時機執行。巡覽中學到的內容也以 PR 保留在儲存庫中,作為下次的輸入。

2.9.2. 結果的交接

巡覽的結果以報告、螢幕截圖、記錄、機器可讀的結果檔案保留,CI 讀取結果檔案並通知至 Slack。測量本身的結果在儀表板上,因此通知只需包含上傳成功與否以及儀表板的連結即可。比較中發現惡化的日子,會附上登錄的 issue 連結通知到同一頻道。相反地,沒有上傳的日子,也會在通知中註明未進行測量。因為測量在不知不覺中停止的狀態最難被發現。

2.10. 今後的課題

由於快照與擷取是在同一次巡覽中取得,兩種資料會互相干擾。取得快照時影格會停止數秒,因此在同時進行的擷取中會被記錄為尖峰。反過來,擷取期間 Profiler 緩衝區的記憶體會包含在快照中,讀取資料時需要注意。若想精確地查看資料,將快照用與擷取用的巡覽分開也是一個選項,但借用裝置的時間會約為兩倍,因此需要在降低頻率等方面做出取捨。

Profiler 擷取的粒度也有課題。每日測量關閉了 Deep Profiling,因此遊戲端的腳本只能以 MonoBehaviour 的 Update 這類單位來觀察。想更細緻觀察的地方,就需要手動加入 ProfilerMarker 或啟用 Deep Profiling。因此,我們正在探索在建置時的後處理中,自動為指定組件的方法插入 ProfilerMarker 等方法。這可以當作縮小範圍的輕量 Deep Profiling 來使用,預期能在抑制資料量與負載的同時,取得更細緻的明細。

2.11. 總結

我們在每日建置之後銜接了一項工作:從裝置農場借用裝置,以應用程式內遠端操作驅動遊戲,將記憶體快照與篩選後的 Profiler 擷取傳送至測量 SDK。藉由在中繼資料中附上 commit hash,測量結果成為以 commit 為單位的歷史記錄;以 CLI 取出後,從偵測惡化、登錄 issue 到建立修正 PR,都能交給 AI 代理。

本章的配置看起來或許規模龐大,但不需要一次備齊所有要素。若您正計畫將測量自動化,不妨先在建置機器上以 USB 連接一台實機,透過 adb 由以反射運作的直譯器擷取記憶體快照,光是這樣,或許就已是相當有價值的記憶體定點觀測。希望本章能為各位測量自動化的第一步提供幫助。

[*1] DeviceFarmer/stf(OpenSTF 的後繼儲存庫) https://github.com/DeviceFarmer/stf

[*2] sebastienros/jint, JavaScript Interpreter for .NET https://github.com/sebastienros/jint