一、背景
在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,而提交指令的函数操作会立刻返回。