作者 魏國梁:位元組 Flutter Infra 工程師, Flutter Member,長期專註 Flutter 引擎技術 袁 欣:位元組 Flutter Infra 工程師, 長期關註渲染技術發展 謝昊辰:位元組 Flutter Infra 工程師,Impeller Contributor Impel ...
作者
- 魏國梁:位元組 Flutter Infra 工程師, Flutter Member,長期專註 Flutter 引擎技術
- 袁 欣:位元組 Flutter Infra 工程師, 長期關註渲染技術發展
- 謝昊辰:位元組 Flutter Infra 工程師,Impeller Contributor
Impeller項目啟動背景
2022 年 6 月在 Flutter 3.0 版本中 Google 官方正式將渲染器 Impeller 從獨立倉庫中合入 Flutter Engine 主幹進行迭代,這是 2021 年 Flutter 團隊推動重新實現 Flutter 渲染後端以來,首次正式明確了 Impeller 未來代替 Skia 作為 Flutter 主渲染方案的定位。Impeller 的出現是 Flutter 團隊用以徹底解決 SkSL(Skia Shading Language) 引入的 Jank 問題所做的重要嘗試。官方首次註意到 Flutter 的 Jank 問題是在 2015 年,當時推出的最重要的優化是對 Dart 代碼使用 AOT 編譯優化執行效率。在 Impeller出現之前,Flutter 對渲染性能的優化大多停留在 Skia 上層,如渲染線程優先順序的提升,在著色器編譯過久的情況下切換 CPU 繪製等策略性優化。
Jank 類型分為兩種:首次運行卡頓(Early-onset Jank)和非首次運行卡頓, Early-onset Jank 的本質是運行時著色器的編譯行為阻塞了 Flutter Raster 線程對渲染指令的提交。在 Native 應用中,開發者通常會基於 UIkit 等系統級別的 UI 框架開發應用,極少需要自定義著色器,Core Animation 等 framework 使用的著色器在 OS 啟動階段就可以完成編譯,著色器編譯產物對所有的 app 而言全局共用,所以 Native 應用極少出現著色器編譯引起的性能問題 , 更常見的是用戶邏輯對 UI 線程過度占用 。 官方為了優化 Early-onset Jank ,推出了SkSL 的 Warmup 方案,Warmup 本質是將部分性能敏感的 SkSL 生成時間前置到編譯期,仍然需要在運行時將 SkSL 轉換為 MSL 才能在 GPU 上執行。Warmup 方案需要在開發期間在真實設備上捕獲 SkSL 導出配置文件 , 在應用打包時通過編譯參數可以將部分 SkSL 預置在應用中。此外由於 SkSL 創建過程中捕獲了用戶設備特定的參數,不同設備 Warmup 配置文件不能相互通用,這種方案帶來的性能提升非常有限。
在 2019 年 Apple 宣佈在其生態中廢棄 OpenGL 後, Flutter 迅速完成了渲染層對 Metal 的適配。與預期不符的是, Metal 的切換使得 Early-onset Jank 的情況更加惡化,Warmup 方案的實現需要依賴 Skia 團隊對 Metal 的預編譯做支持,由於 Skia 團隊的排期問題,一度導致 Warmup 方案在 Metal 後端上不可用。與此同時社區中對 iOS 平臺 Jank 問題的反饋更加強烈,社區中一度出現屏蔽 Metal 的 Flutter Engine Build,回退到 GL 後端雖然能一定程度改善首幀性能但是在 iOS 平臺上會出現視覺效果的退化,與之相對的是,由於 Android 平臺上擁有 iOS 缺失的著色器機器碼的緩存能力, Android 平臺出現 Jank 的概率比 iOS 低很多。
除了社區中出現的通用問題外,Flutter infra 團隊也經常收到位元組內部業務方遇到的 Jank 問題的反饋,反饋較集中的有轉場動畫首次卡頓、列表滾動過程中隨機卡頓等場景:
轉場動畫觸發的著色器編譯,耗時~100ms
列表滑動過程中隨機觸發的著色器編譯,耗時~28ms
在這篇文章中,我們嘗試從 Metal 著色器編譯方案,矢量渲染器原理和 Flutter Engine 渲染層的介面設計三個維度去探究 Impeller 想要解決的問題和渲染器背後的相關技術。
Metal Shader Compilation演進
一般而言,不同的渲染後端會使用獨立的著色器語言,與 JavaScript 等常見腳本語言的執行過程類似,不同語言編寫的著色器程式為了能在 GPU 硬體上執行,需要經歷完整的 lexical analysis / syntax analysis / Abstrat Syntax Tree (抽象語法樹,下文簡稱 AST)構建,IR 優化,binary generation 的過程。著色器的編譯處理是在廠商提供的驅動中實現,其中具體的實現對上層開發者並不可見。Mesa 是一個在 MIT 許可證下開源的三維電腦圖形庫,以開源形式實現了 OpenGL 的 api 介面。通過 Mesa 中對 GLSL 的處理可以觀察到完整的著色器處理流水線。如下圖所示,上層提供的 GLSL 源文件被 Mesa 處理為 AST 後首先會被編譯為 GLSL IR, 這是一種 High-Level IR,經過優化後會生成另一種 Low-Level IR :NIR,NIR 結合當前 GPU 的硬體信息被處理為真正的可執行文件。不同的 IR 用來執行不同粒度的優化操作,通常底層 IR 更面向可執行文件的生成,而上層 IR 可以進行諸如 dead code elimination 等粗粒度優化。常見的高級語言(如 Swift )的編譯過程也存在 High-Level IR (Swift IL) 到 Low-Level IR (LLVM IR)的轉換。
隨著 Vulkan 的發展, OpenGL 4.6 標準中引入了對 SPIR-V 格式的支持。SPIR-V(Standard Portable Intermediate Representation)是一種標準化的 IR,統一了圖形著色器語言與並行計算(GPGPU 應用)領域。它允許不同的著色器語言轉化為標準化的中間表示,以便優化或轉化為其他高級語言,或直接傳給Vulkan、OpenGL 或 OpenCL 驅動執行。SPIR-V 消除了設備驅動程式中對高級語言前端編譯器的需求,大大降低了驅動程式的複雜性,使廣泛的語言和框架前端能夠在不同的硬體架構上運行。Mesa 中使用 SPIR-V 格式的著色器程式可以在編譯時直接對接到 NIR 層,縮短著色器機器碼編譯的開銷, 有助於系統渲染性能的提升。
在 Metal 應用中, 使用 Metal Shading Language(以下簡稱 MSL )編寫的著色器源碼首先被處理為 AIR (Apple IR) 格式的中間表示。如果著色器源碼是以字元形式在工程中引用,這一步會在運行時在用戶設備上進行,如果著色器被添加為工程的Target,著色器源碼會在編譯期在 Xcode 中跟隨項目構建生成 MetalLib: 一種設計用來存放 AIR 的容器格式。隨後 AIR 會在運行時,根據當前設備 GPU 的硬體信息,被 Metal Compiler Service 用 JIT 編譯為可供執行的機器碼。相比源碼形式,將著色器源碼打包為 MetalLib 有助於降低運行時生著色器機器碼的開銷。著色器機器碼的編譯會在每一次渲染管線狀態對象(P ipeline S tate O bject,下文簡稱 PSO)創建時發生,一個 PSO 持有當前渲染管線關聯的所有狀態,包含光柵化各階段的著色器機器碼,顏色混合狀態,深度信息,模版掩碼狀態,多重採樣信息等等。PSO 通常被設計為一個 imutable object(不可變對象),如果需要更改 PSO 中的狀態需要創建一個新的 PSO 拷貝。
由於 PSO 可能在應用生命周期中多次創建, 為了防止著色器的重覆編譯開銷,所有編譯過的著色器機器碼會被 Metal 緩存用來加速後續 PSO 的創建過程,這個緩存稱為 Metal Shader Cache ,完全由 Metal 內部管理,不受開發者控制。應用通常會在啟動階段一次性創建大量 PSO 對象,由於此時 Metal 中沒有任何著色器的編譯緩存,PSO 的創建會觸發所有的著色器完整執行從 AIR 到機器碼的編譯過程,整個集中編譯階段是一個 CPU 密集型操作。在游戲中通常在玩家進入新關卡前利用 Loading Screen 準備好下一場景所需的 PSO,然而常規 app 中用戶的預期是能夠即點即用,一旦著色器編譯時間超過 16 ms,用戶就會感受到明顯的卡頓和掉幀。
在 Metal 2 中, Apple 首次為開發者引入了手動控制著色器緩存的能力:Metal Binary Archive。Metal Binary Archive 的緩存層次位於 Metal Shader Cache 之上, 這意味著 Metal Binary Archive 中的緩存在 PSO 創建時會被優先使用 。 在運行時,開發者可以通過 Metal Pipeline Manager 手動將性能敏感的著色器函數添加至 Metal Binary Archive 對象中並序列化至磁碟中。應用再次冷啟後,此時創建相同的 PSO 即是一個輕量化操作,沒有任何著色器編譯開銷。緩存的 Binary Archive 甚至可以二次分發給相同設備的用戶,如果本地 Binary Archive 中緩存的機器碼與當前設備的硬體信息不匹配,Metal 會回落至完整的編譯流水線,確保應用的正常執行。游戲堡壘之夜「Fortnite」 在啟動階段需要創建多達 1700 個 PSO 對象,通過使用 Metal Binary Archive 來加速 PSO 創建,啟動耗時從 1m26s 優化為 3s , 速度提升28倍。
Metal Binary Archive 通過記憶體映射的方式供 GPU 直接訪問文件系統中的著色器緩存,因此打開 Metal Binary Archive 時會占用設備寶貴的虛擬記憶體地址空間。與緩存所有的著色器函數相比,更明智的做法是根據具體的業務場景將緩存分層,在頁面退出後及時關閉對應的緩存 , 釋放不必要的虛擬記憶體空間。Metal Shader Cache 的黑盒管理機制無法保證著色器在使用時不會出現二次編譯 , 而 Metal Binary Archive 可以確保其中的緩存的著色器函數在應用生命周期內始終可用。Metal Binary Archive 雖然允許開發者手動管理著色器緩存,卻依然需要通過在運行時搜集機器碼來構建,無法保證應用初次安裝時的使用體驗。在 2022 年 WWDC 中,Metal 3 終於彌補了這個遺留的缺陷,為開發者帶來了在離線構建 Metal Binary Archive 的能力:
構建離線 Metal Binary Archive 需要使用一種全新的配置文件 Pipeline Script,Pipeline Script 其實是 Pipeline State Descriptor 的一種 JSON 表示,其中配置了 PSO 創建所需的各種狀態信息,開發者可以直接編輯生成,也可以在運行時捕獲 PSO 獲得。給定 Pipeline Script 和 MetalLib,通過 Metal 工具鏈提供的 metal 命令即可離線構建出包含著色器機器碼的 Metal Binary Archive。Metal Binary Archive 中的機器碼可能會包含多種 GPU 架構 , 由於 Metal Binary Archive 需要內置在應用中提交市場 , 開發者可以綜合考慮包體積的因素剔除不必要的架構支持。
通過離線構建 Metal Binary Archive,著色器編譯的開銷只存在於編譯階段,應用啟動階段 PSO 的創建開銷大大降低。Metal Binary Archive 不止可以優化應用的首屏性能, 真實的業務場景下,一些 PSO 對象會遲滯到具體頁面才會被創建,觸發新的著色器編譯流程。一旦編譯耗時過長,就會影響當前 RunLoop 下 Metal 繪製指令的提交, Metal Binary Archive 可以確保在應用的生命周期內, 核心交互路徑下的著色器緩存始終為可用狀態,將節省的 CPU 時間片用來處理與用戶交互強相關的邏輯, 大大提升應用的響應性和使用體驗。
矢量渲染基礎概念
矢量渲染泛指在平面坐標系內通過組裝幾何圖元來生成圖像信息的手段,通過定義一套完整的繪製指令,可以在不同的終端上還原出不失真的圖形, 任何前端的視窗都可以被看作一個 2D 平面的矢量渲染畫布,Chrome 與 Android 渲染系統就是基於 Google 的 2D 圖形庫 Skia 構建。對應用開發而言,矢量渲染技術也扮演重要角色,如文本 / 圖表 / 地圖 / SVG / Lottie 等都依賴矢量渲染能力來提供高品質的視覺效果。
矢量渲染的基礎單元是 Path(路徑),Path 可以包含單個或多個 Contour(輪廓),Contour在一些渲染器中也稱為 SubPath,Contour 由連續的 Segment(直線/高階貝塞爾曲線)組成,標準的幾何構型(圓形/矩形)均可被視為一種特殊的 Path,一些特殊的 Path 可以包含坑洞或者自交叉(如五角星⭐️),這類 Path 的處理需要一些特殊的方案。圍繞 Path 可以構造出各種複雜的圖形,著名的老虎