一般最佳化技巧
前言
在理想世界中,電腦能以無限的速度運作,唯一的限制就是我們的想像力。然而在現實中,要寫出讓即使是最快電腦也跑不動的軟體,其實非常容易。
因此,設計遊戲或其他軟體時,我們必須在「想做的事情」和「在維持良好效能下實際能做到的事情」之間取得平衡。
要達到最佳結果,我們有兩種方法:
讓運作更快。
讓方法更聰明。
理想上,兩者應該兼顧。
障眼法與假象
聰明地開發的一環,就是知道在遊戲裡,我們經常能讓玩家相信他們正處在一個比實際更複雜、更互動、畫面更華麗的世界。一個好的程式設計師就像魔術師,應該不斷學習業界的技巧,並試著發明新手法。
效能瓶頸的本質
對外行人來說,所有效能問題看起來都一樣,但其實效能問題有不同類型:
每一幀都會發生的慢處理,導致持續低幀率。
間歇性發生、造成效能「尖峰」導致遊戲卡頓的處理。
在一般遊戲流程外才出現的慢處理,比如載入關卡時。
這些問題都會讓玩家感到困擾,只是方式不同。
效能量測
進行效能優化時,最重要的工具就是能夠量測效能:也就是能夠找出瓶頸在哪裡,並評估我們的優化措施是否有效。
量測效能有幾種常見方法,包括:
針對想觀察的程式碼區塊加上啟動/停止計時器。
使用 Godot 分析器。
使用 外部 CPU 分析工具。
使用外部 GPU 分析工具或偵錯器,例如 NVIDIA Nsight Graphics、Radeon GPU Profiler、Intel Graphics Performance Analyzers,或 Arm Performance Studio。
檢查幀率(建議關閉 V-Sync ),也可以使用第三方工具,例如 Windows 上的 RivaTuner Statistics Server,或 Linux 上的 MangoHud。
使用非官方的 除錯選單擴充外掛。
要特別注意,不同的硬體設備上,不同區塊的效能表現可能差異很大。建議在多個裝置上量測效能,若目標平台包含行動裝置尤其要注意這點。
限制
CPU 分析器通常是效能分析的首選工具,但它們不一定能呈現全部事實。
瓶頸也常常會在 GPU,那是 CPU 發出指令後的「結果」。
有些效能尖峰,其實是在作業系統層級(Godot 之外)發生,是 Godot 執行某些操作(例如動態記憶體配置)後的「結果」。
由於初始設定繁複,你不一定能隨時在特定裝置(例如手機)做效能分析。
有時你甚至得解決那些你手邊沒有的硬體上發生的效能問題。
因此,你往往需要靠「偵探式」的排查手法來找出瓶頸所在。
偵探式排查
偵探式排查對開發者而言非常重要,不只用在效能問題,也常用於除錯。這包括假設驗證和二分搜尋法。
假設驗證
假設你懷疑遊戲慢是因為精靈(Sprite)太多,可以這麼驗證:
嘗試增加或減少精靈數量,觀察效能變化。
這又引發新假設:是否精靈圖片尺寸越大,效能掉越多?
那就只改變精靈尺寸,保持其他條件不變,然後量測效能來驗證。
二分搜尋法
如果你知道每幀花的時間明顯超過預期,但不確定是哪裡出問題,可以先把一半的主要處理程式碼註解掉,然後觀察效能提升幅度是否如預期?
一旦知道是哪一半有瓶頸,再繼續對該部分重複此步驟,直到確定是哪裡出了問題。
效能分析工具
效能分析工具(Profiler)可以在程式執行時記錄各區塊的耗時,並統計每個函式或模組佔用的時間百分比,以及函式被呼叫的次數。
這對找出瓶頸、量測改善效果都很有幫助。有時候優化反而讓效能變差,所以務必要用效能分析和計時來指引你的優化方向。
有關使用 Godot 內建分析器的更多資訊,請參考 效能分析器。
原則
Donald Knuth 說:
程式設計師花了大量時間去思考或擔心程式中非關鍵部分的效能,但這些效率上的嘗試,事實上在除錯和維護時反而帶來負面影響。我們應該忘掉那些微小的效能提升,也就是約 97% 的情況:過早的最佳化是萬惡之源。但對於那關鍵的 3%,我們不能錯過機會。
這些訊息非常重要:
開發時間有限,與其盲目優化所有環節,不如將心力集中在真正重要的部分。
許多優化反而讓程式碼難以閱讀和除錯。我們應該只在真正有顯著效益的地方進行這些犧牲。
不是因為我們 能 優化某段程式碼,就一定 應該 去優化。知道什麼時候該優化、什麼時候不該動,這是一項很重要的能力。
這段話常被誤解,人們容易只記得「過早的最佳化是萬惡之源」這句。雖然 過早 的最佳化確實不可取,但高效能軟體其實來自於一開始就有高效能的設計。
高效能設計
如果一直到很後面才考慮效能,最大風險就是錯過了設計階段。實際上,最重要的效能決定早在還沒寫第一行程式碼前就已經發生。如果設計和演算法本身就低效,後面再怎麼調整細節,頂多只能「變快一點」,永遠追不上原本設計就為效能考量的程式。
這點在遊戲或圖形程式設計中特別重要。一個設計得當的系統,即使沒做底層最佳化,也常常比設計平庸但拼命微調底層的系統快上好幾倍。
漸進式設計
當然,實務上除非你本來就很有經驗,很難第一次就設計出最理想的架構。通常都會針對某個問題嘗試許多不同方法,做出多個版本,最後才找到滿意的解決方案。在這個階段,別太早花太多時間雕琢細節,等整體設計底定後再來優化細節,不然前面做的多半都會被捨棄。
高效能設計沒有放諸四海皆準的準則,因為每個問題本質不同。不過有個 CPU 方面的原則很值得注意:現代 CPU 幾乎都被記憶體頻寬限制住。因此,資料導向(Data-Oriented)的設計重新受到重視,設計資料結構與演算法時要盡量讓資料存取具有 快取區域性 (cache locality)和線性存取,而非隨機跳躍於記憶體。
最佳化流程
假設我們已經有合理的設計,並記住 Knuth 的建議,最佳化的第一步就是找出最大的瓶頸:那些最慢的函式,也就是「低垂的果實」。
當我們成功改善了最慢的部分後,它可能就不再是瓶頸,這時就要再重新測試/分析,找出下個最值得優化的區塊。
流程如下:
分析/找出瓶頸。
優化瓶頸。
回到步驟 1。
瓶頸最佳化
有些分析工具甚至能指出是哪個函式的哪個部分(哪個資料存取、哪個運算)拖慢速度。
和設計階段一樣,優化時首先要確保演算法和資料結構已是最佳選擇。資料存取應盡量區域化(善用 CPU 快取),且資料儲存應盡量精簡(同樣記得用分析工具驗證)。許多複雜運算可以事先預先計算好,例如在載入關卡時處理、或直接載入已經預先算好資料的檔案,又或者把運算結果寫成腳本常數直接讀取。
當演算法和資料結構最佳化後,還可以針對細部流程做些小調整來提升效能,例如把某些計算移到迴圈外,或將巢狀 for 迴圈改寫為非巢狀(前提是你已知 2D 陣列的寬高)
每做一項更動,記得都要重新測試瓶頸和耗時。有些改動會讓速度變快,有些則讓效能變差。有時效能提升很小,但程式碼變得很難維護,這種情況下也可以選擇不做這類優化。
附錄
瓶頸數學
俗話說:「一條鍊子的強度取決於最薄弱的環節」,這句話用在效能優化再貼切不過。如果你的專案有 90% 的執行時間都花在函式 A,那麼只要優化 A,就能有極大效益。
A: 9 ms
Everything else: 1 ms
Total frame time: 10 ms
A: 1 ms
Everything else: 1ms
Total frame time: 2 ms
在這個例子中,若將瓶頸 A 提升 9 倍效能,總體幀時間會縮短 5 倍,每秒幀數也會提升 5 倍。
不過,如果還有其他地方也很慢、同樣成為瓶頸時,相同的優化幅度就無法帶來這麼明顯的提升:
A: 9 ms
Everything else: 50 ms
Total frame time: 59 ms
A: 1 ms
Everything else: 50 ms
Total frame time: 51 ms
在這個例子中,雖然我們大幅優化了函式 A,但實際上幀率提升卻很有限。
在遊戲開發中,情況更複雜,因為 CPU 和 GPU 是獨立運作的。你的總幀時間取決於兩者裡面較慢的那一方。
CPU: 9 ms
GPU: 50 ms
Total frame time: 50 ms
CPU: 1 ms
GPU: 50 ms
Total frame time: 50 ms
在這個例子裡,我們大幅優化了 CPU,但幀時間卻沒變快,因為這次的瓶頸是在 GPU。