一、背景
在 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 绘制流程
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));
Impeller 残缺的着色器解决流水线如下图所示。
四、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,而提交指令的函数操作会立刻返回。