作者
- 魏国梁:字节 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