一、背景
在2022年6月Google官方发布的Flutter 3.0 版本中,正式将渲染器 Impeller 从独立仓库中合入 Flutter Engine 主干进行迭代,这是2021年Flutter团队推动重新实现Flutter渲染后端以来,首次明确了Impeller未来代替 Skia 作为 Flutter 主渲染方案的定位。Impeller的出现是Flutter团队用以彻底解决 SkSL(Skia Shading Language)引入的Jank问题所做的重要尝试,那什么是着色器编译Jank问题呢?
众所周知,Flutter底层使用了skia做为2D图形渲染库,而skia内部定义了一套SkSL(Skia shading language),SkSL 属于 GLSL 变体。这句话怎么理解呢?通俗的讲,SkSL就是基于某一固定版本的GLSL语法进行的设计,其作用是抹去不同GPU驱动API着色器语法的差异,以便对不同的GPU驱动进一步输出为目标着色器语言,因此SkSL可以看做是着色器的预编译语言。
在Flutter的光栅化阶段,第一次使用着色器时Skia会根据绘图命令和设备参数生成 SkSL,然后再将 SkSL 转换为特定后端(GLSL、GLSL ES 或 Metal SL)着色器,并在设备上编译为着色器程序。而编译着色器可能花费几百毫秒,导致数十帧的丢失。定位着色器编译Jank问题可以查看trace信息是否有 GrGLProgramBuilder::finalize 调用,比如。
官方首次注意到 Flutter的Jank问题是在 2015 年,当时推出的最重要的优化是对Dart代码使用 AOT编译优化执行效率,不过Jank也带来了新的问题。 Jank问题可分为首次运行卡顿(Early-onset Jank)和非首次运行卡顿,首次运行卡顿的本质是运行时着色器的编译行为阻塞了 Flutter Raster 线程对渲染指令的提交。在原生应用开发过程中,开发者通常会基于UIkit等系统级别的UI框架开发应用,极少使用自定义的着色器,所以原生应用极少出现着色器编译引起的性能问题,更常见的是用户逻辑对 UI 线程过度占用。
官方为了优化首次运行卡顿 ,推出了SkSL的Warmup方案,Warmup方案的原理是将部分性能敏感的 SkSL 生成时间前置到编译期,仍然需要在运行时将SkSL转换为MSL才能在GPU上执行。Warmup方案需要在开发期间在真实设备上捕获 SkSL 导出配置文件,在应用打包时通过编译参数可以将部分SkSL预置在应用中。此外由于 SkSL 创建过程中捕获了用户设备特定的参数,不同设备 Warmup 配置文件不能相互通用,这种方案带来的性能提升非常有限。
同时,给Warmup方案带来的另一个挑战是,Apple 在2019年宣布在其生态中废弃OpenGL技术,转而使用新的Metal渲染器,为此,Flutter在2.5版本完成了对Metal渲染器的适配。不过,与预期不符的是,Metal的切换使得Early-onset Jank的情况更加恶化,Warmup方案的实现需要依赖Skia团队对Metal的预编译做支持,由于Skia团队的排期问题,一度导致Warmup方案在 Metal后端上不可用。与此同时社区中对 iOS 平台 Jank 问题的反馈更加强烈,社区中一度出现屏蔽Metal的Flutter Engine Build,回退到GL后端。与之相对的是,由于Android平台拥有 iOS 缺失的着色器机器码的缓存能力,Android 平台出现 Jank 的概率比 iOS 低很多。
在Impeller出现之前,Flutter 对渲染性能的优化大多停留在 Skia 上层,如渲染线程优先级的提升,在着色器编译过久的情况下切换 CPU 绘制等策略性优化。为了彻底解决Jank问题,Flutter官方决定重新考虑使用着色器的方式重写图像渲染后端,即新的impeller图形渲染后端方案。
二、Metal演进
一般来说,不同的渲染后端采用的着色器语言也是不一样的,不过执行的流程确实一样的。与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格式的支持。这里需要说明一下,Vulkan和OpenGL都属于跨平台图像渲染引擎,都是Khronos旗下的产品。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 有助于降低运行时生着色器机器码的开销。 着色器机器码的编译会在每一次渲染管线状态对象(Pipeline State Object,下文简称 PSO)创建时发生,一个PSO持有当前渲染管线关联的所有状态,包含光栅化各阶段的着色器机器码,颜色混合状态、深度信息、模版掩码状态、多重采样信息等等。PSO 通常被设计为一个 immutable 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 时间片用来处理与用户交互强相关的逻辑, 大大提升应用的响应性和使用体验。
三、Impeller架构与渲染流程
3.1 Impeller架构
Impeller是为Flutter量身定做的渲染器,目前还处于早期的开发和试验阶段,仅实现了metal后端,以及iOS和Mac系统支持。工程方面,他依赖了flutter fml 和 display list,并实现了display list dispatcher接口,可以容易的替换skia。
Impeller核心目标:
- 可预测的性能:在编译时离线编译所有着色器,并根据着色器预先构建 pipeline state objects。
- 可检测:所有的图形资源(textures、buffers、pipeline state对象等)都被追踪和标记。动画可以被捕获并持久化到磁盘而不影响渲染性能。
- 可移植:没有与特定的渲染API相绑定,着色器编写一次并在需要时转换。
- 使用现代图形API:大量使用(但不依赖)现代图形API(如Metal和Vulkan)的特性。
- 有效利用并发性:可以在多线程上分发单帧工作负载。
总的来说,Impeller 的目标是为 Flutter 提供具备 predictable performance 的渲染支持,Skia 的渲染机制需要应用在启动过程中动态生成 SkSL,这一部分着色器需要在运行时转换为 MSL,才能进一步被编译为可执行的机器码,整个编译过程会对 Raster 线程形成阻塞。Impeller 放弃了使用 SkSL 转而使用 GLSL 4.6 作为上层的着色器语言,通过 Impeller 内置的 ImpellerC 编译器,在编译期即可将所有的着色器转换为 Metal Shading language, 并使用 MetalLib 格式打包为 AIR 字节码内置在应用中。Impeller 的另一个优势是大量使用 Modern Graphics APIs ,Metal 的设计可以充分利用 CPU 多核优势并行提交渲染指令, 大幅减少了驱动层对 PSO 的状态校验, 相对于 GL 后端仅仅将上层渲染接口的调用切换为 Metal 就可以为应用带来约 ~10% 的渲染性能提升。
impeller大致可以分为Compiler、Renderer,Entity、Aiks以及基础库Geomety和Base等几个模块,整体架构如下。
- Compiler:Host端工具,包含着色器Compiler和Reflector。Compiler用于把 GLSL 4.60 着色器源码离线编译为特定后端的着色器(如MSL)。Reflector根据着色器离线生成C++ shader bindings,以便在运行时快速构建pipeline state objects (PSO)。
- Renderer:用于创建buffer、从shader bindings生成pipeline state objects、设置RenderPass、管理uniform-buffers、细分曲面、执行渲染任务等
- Entity:用于构建2D渲染器,包含了着色器shader bindings和pipeline state objects
- Aiks:封装 Entity 以提供类 Skia API,便于对接到Flutter。
3.2 Impeller 绘制流程
总的来说,Flutter Engine 层的 LayerTree 在被 Impeller 绘制前需要首先被转换为 EntityPassTree,UI 线程在接收到 v-sync 信号后会将 LayerTree 从UI 线程提交到 Raster 线程,在Raster线程中会遍历LayerTree的每个节点并通过DisplayListRecorder记录各个节点的绘制信息以及saveLayer操作,LayerTree中可以做可以Raster Cache的子树其绘制结果会被缓存为位图, DisplayListRecorder 会将对应子树的绘制操作转换为 drawImage 操作,加速后续渲染速度。
DisplayListRecorder 完成指令录制后,就可以提交当前帧。 DisplayListRecorder中的指令缓存会被用来创建DisplayList对象,DisplayList被DisplayListDispatcher的实现者(Skia / Impeller)消费,回放 DisplayList其中所有的 DisplayListOptions 可以将绘制操作转换为 EntityPassTree。
完成 EntityPassTree 的构建之后,需要把 EntityPassTree 中的指令解析出来执行。EntityPassTree 绘制操作以 Entity 对象为单位,Impeller 中使用 Vector 来管理一个绘制上下文中多个不同的 Entity 对象。 通常 Canvas 在执行复杂绘制操作时会使用 SaveLayer 开辟一个新的绘制上下文,在 iOS 上称为离屏渲染, SaveLayer 操作在 Impeller 中会被标记为创建一个新的 EntityPass,用于记录独立上下文中的 Entity,新的 EntityPass 会被记录到父节点的 EntityPass 列表中, EntityPass 的创建流程如下图所示。
Metal 在上层为设备的GPU 硬件抽象了CommandQueue 的概念,CommandQueue 与 GPU 数量一一对应,CommandQueue 中可包含一个或者多个 CommandBuffer。CommandBuffer 是实际绘制指令 RenderCommand 存放的队列,简单的应用可以只包含一个 CommandBuffer,不同的线程可以通过持有不同CommandBuffer来加速 RenderCommand的提交。RenderCommand由RenderCommandEncoder的Encode操作产生,RenderCommandEncoder 定义了此次绘制结果的保存方式,绘制结果的像素格式以及绘制开始或结束时 Framebuffer attachmement 所需要做的操作(clear / store),RenderCommand 包含了最终交付给 Metal 的真实 drawcall 操作。
Entity 中的 Command 转化为真正的MTLRenderCommand 时,还携带了一个重要的信息PSO 。Entity 从 DisplayList 中继承的绘制状态最终会变为 MTLRenderCommand 关联的 PSO ,MTLRenderCommand 被消费时 Metal 驱动层会首先读取 PSO 调整渲染管线状态,再执行着色器进行绘制,完成当前的绘制操作,流程如下。
3.3 ImpellerC 编译器设计
ImpellerC 是Impeller内置的着色器编译解决方案,源码位于Impeller 的compiler目录下 ,它能够在编译期将Impeller上层编写的glsl源文件转化为两个产物:1. 目标平台对应的着色器文件;2. 根据着色器 uniform 信息生成的反射文件,其中包含了着色器 uniform 的 struct 布局等信息。
其中,反射文件中的 struct 类型作为 model 层,使得上层使用无需关心具体后端的 uniform 赋值方式,极大地增强了 Impeller 的跨平台属性,为编写不同平台的着色器代码提供了便利。在编译Flutter Engine工程中Impeller部分时,gn会首先将compiler目录下的文件编译出为 ImpellerC 可执行文件,再使用ImpellerC对entity/content/shaders目录下的所有着色器进行预处理。GL后端会将着色器源码处理为hex格式并整合到一个头文件中, 而Metal后端会在GLSL完成MSL的转译后进一步处理为MetalLib。
ImpellerC在处理glsl源文件时,会调用shaderc对glsl文件进行编译。shaderc是Google维护的着色器编译器,可以glsl源码编译为SPIR-V。shaderc 的编译过程使用了glslang 和 SPIRV-Tools 两个开源工具。其中,glslang是glsl的编译前端,负责将 glsl处理为AST , SPIRV-Tools可以接管剩下的工作将AST进一步编译为SPIR-V,在这一步的编译过程中,为了能得到正确的反射信息,ImpellerC会对shaderc限制优化等级。
随后 ImpellerC 会调用 SPIR-V Cross 对上一步骤得到的 SPIR-V 进行反汇编,得到 SPIR-V IR ,这是一种 SPIR-V Cross 内部使用的数据结构,SPIR-V Cross 会在其之上进行进一步优化。ImpellerC 随后会调用 SPIR-V Cross 创建目标平台的 Compiler Backend(MSLCompiler / GLSLCompiler / SKSLCompiler), Compiler Backend 中封装了目标平台着色器语言的具体转译逻辑 。同时 SPIR-V Cross 会从 SPIR-V IR 中提取 Uniform 数量,变量类型和偏移值等反射信息
struct ShaderStructMemberMetadata {
ShaderType type; // the data type (bool, int, float, etc.)
std::string name; // the uniform member name "frame_info.mvp"
size_t offset;
size_t size;
};
Reflector在得到这些信息后,会对内置的 .h 与 .cc 模版进行填充,得到可供Impeller引用的 .h 与.cc 文件,上层可以反射文件的类型方便的生成数据memcpy得到对应的buffer中实现与着色器的通讯。对于Metal和GLES3来说,由于原生支持UBO,最终会通过对应后端提供的UBO 接口来实现传值,对于不支持 UBO 的GLES2 来说,对UBO的赋值需要转换为 glUniform* 系列 API 对 Uniform 中每个字段的单独赋值,在 shader program link 后,Impeller 在运行时通过 glGetUniformLocation 得到所有字段在 buffer 中的位置,与反射文件中提取出的偏移值结合,Impeller 就可以得到每个 Uniform 字段的位置信息,这个过程会在 Imepller Context 创建时生成一次,随后 Impeller 会维护 Uniform 字段的信息。对于上层来说,不管是 GLES2 还是其他后端, 通过 Reflector 与着色器的通讯过程都是一样的。
完成着色器转译和反射文件提取后,就可以实际执行 uniform 数据的绑定,Entity 在触发绘制操作时会首先调用 Content 的 Render 函数, 其中会创建一个供 Metal 消费的 Command 对象,Command 会提交到 RenderPass 中等待调度, uniform 数据的绑定发生在 Command 创建这一步。如下图所示:VS::FrameInfo 和 FS::GradientInfo 是反射生成的两个 Struct 类型, 初始化 VS::FrameInfo 和 FS::GradientInfo 的实例并赋值后,通过 VS::BindFrameInfo 和 FS::BindGradientInfo 函数即可实现数据和 uniform 的绑定。
VS::FrameInfo frame_info;
frame_info.mvp = Matrix::MakeOrthographic(pass.GetRenderTargetSize()) * entity.GetTransformation();
FS::GradientInfo gradient_info;
gradient_info.start_point = start_point_;
gradient_info.end_point = end_point_;
gradient_info.start_color = colors_[0].Premultiply();
gradient_info.end_color = colors_[1].Premultiply();
Command cmd;
cmd.label = "LinearGradientFill";
cmd.pipeline = renderer.GetGradientFillPipeline(OptionsFromPassAndEntity(pass, entity));
cmd.stencil_reference = entity.GetStencilDepth();
cmd.BindVertices(vertices_builder.CreateVertexBuffer(pass.GetTransientsBuffer()));
cmd.primitive_type = PrimitiveType::kTriangle;
FS::BindGradientInfo(cmd, pass.GetTransientsBuffer().EmplaceUniform(gradient_info));
VS::BindFrameInfo(cmd, pass.GetTransientsBuffer().EmplaceUniform(frame_info));
return pass.AddCommand(std::move(cmd));
四、CommandQueue、CommandList、CommandAllocator的关系
在看一些渲染相关的文章时候,经常会碰到CommandQueue、CommandList、CommandAllocator这几个术语。
CommandQueue(命令队列):在GPU中,是一块环形缓冲区,是GPU的命令执行队列,每个GPU至少维护一个CommandQueue。如果该队列为空,GPU将空闲空转,等到有指令过来;如果该队列满了,将阻塞CPU的执行。
CommandList(命令列表):在CPU中,用来记录GPU的执行指令,我们期望让GPU执行的任务会通过它来记录。
CommandAllocator(命令分配器):在CPU中,用来给CommandList记录的指令分配空间,这个空间用来在CPU侧存储指令,不存储资源。
DX12通过ExecuteCommandLists函数,将CommandList中记录的指令提交给GPU中的CommandQueue。CPU和GPU是两个处理器,它们在两条独立的跑道上并行地跑,CommandQueue就是GPU的跑道。CommandList在调用 设置视口(SetViewPort)、清屏(ClearRenderTarget)、发起绘制(DrawIndex) 这些函数的时候,并没有真正地执行这些操作,只是将这些指令记录下来,直到执行ExecuteCommandLists函数,指令就从CPU送到GPU的CommandQueue中,这个从CPU送GPU过程不一定是立即送的,当然GPU也不是立即调用,而是按CommandQueue中的顺序依次执行指令。
那这三者有什么联系吗?一个GPU至少维护一个CommandQueue。CommandList用于在CPU侧进行指令记录,CommandList可以有多个。创建CommandList要指定分配器CommandAllocator,CommandAllocator也可以有多个,同时一个CommandAllocator可以关联多个CommandList,但是关联同一CommandAllocator的CommandList们不能同时记录指令,因为CommandList记录的指令的内存由CommandAllocator来分配,我们需要保证(((记录的指令)的内存)的连续性),这样才能一把送到CommandQueue中,并发记录会破坏内存连续性。
同时,CommandQueue在GPU中,是GPU的执行跑道,不能重置。CommandList在记录完指令并提交指令后可以重置(然后可以复用它记录新的指令),因为提交指令后,CommandAllocator还在维护着这块内存。在没有确定GPU执行完CommandAllocator中的指令前不能重置CommandAllocator,因为底下实现可能会起Job一点点地去送指令到GPU,而提交指令的函数操作会立即返回。