WWDC Quick Look 💓 By SwiftGGTeam
Discover compilation workflows in Metal

Discover compilation workflows in Metal

观看原视频

Highlight

Metal 在 WWDC21 把动态库、函数指针、二进制归档、私有链接函数和函数拼接扩展到新的 shader 编译流程,让开发者可以复用 GPU 二进制、运行时扩展渲染管线,并减少 shader 编译成本。

核心内容

你写了一个 Metal 渲染器。多个 fragment shader 都会调用同一批工具函数,比如 foo()bar()。旧做法很直接:把实现编进每个 pipeline。项目变大后,重复编译开始拖慢启动和首次渲染。

另一个问题来自运行时配置。材质、滤镜、用户自定义效果,都可能在 App 运行中变化。如果每次变化都重新拼 shader 源码,再从 Metal source 编到 AIR,用户会等,开发者也很难控制缓存粒度。

Apple 在这场 session 里给出几条路径。

固定的工具函数,可以放进动态库(Dynamic Library)。pipeline 创建时再链接它们,同一份 GPU binary 可以服务多个 render、tile、compute 工作负载。

运行时需要替换调用目标,可以使用函数指针(Function Pointer)和可见函数表(Visible Function Table)。shader 只知道函数签名,实际调用哪一个函数,由 CPU 端绑定的 table 决定。

编译成本需要跨启动复用,可以使用二进制归档(Binary Archive)。它把 pipeline 和 binary function pointer 的编译结果保存到磁盘,下一次启动直接查归档。

如果你现在靠字符串拼接生成 shader,函数拼接(Function Stitching)提供了更稳的做法。你把预编译的 [[stitchable]] 函数组成有向无环图,Metal 在 AIR 层生成新函数,跳过 Metal frontend 的运行时开销。

详细内容

动态库:把工具函数从 pipeline 里拆出来

04:44)动态库的流程从 AIR 开始。你可以用 Xcode 的 Metal toolchain 在构建期生成 AIR,也可以在运行时调用 newLibraryWithSource。拿到 AIR 后,用 newDynamicLibrary 创建动态库,用 serializeToURL 写入磁盘,下一次用 newDynamicLibraryWithURL 复用。

id<MTLLibrary> airLibrary = [device newLibraryWithSource:source
                                                 options:nil
                                                   error:&error];

id<MTLDynamicLibrary> dynamicLibrary = [device newDynamicLibrary:airLibrary
                                                           error:&error];

[dynamicLibrary serializeToURL:libraryURL
                         error:&error];

id<MTLDynamicLibrary> cachedLibrary = [device newDynamicLibraryWithURL:libraryURL
                                                                  error:&error];

关键点:

  • newLibraryWithSource 对应 transcript 中提到的运行时从 source 编译到 AIR 的入口
  • newDynamicLibrary 把已经编译出的 Metal library 转成 GPU binary 形式的动态库
  • serializeToURL 把动态库写到磁盘,供后续启动复用
  • newDynamicLibraryWithURL 从磁盘加载之前保存的动态库

05:38)shader 里只声明外部函数,不提供实现。实现来自创建 pipeline 时加载的动态库。

// Declare external functions

extern float4 foo(FragmentInput input);
extern float4 bar(FragmentInput input);

// Use functions in shader

fragment float4 main(FragmentInput input [[stage_in]])
{
    switch(condition(input))
    {
        case 0:
            return foo(input);
        case 1:
            return bar(input);
    }
}

关键点:

  • extern float4 foo(...) 声明函数签名,当前 shader 文件不提供函数体
  • extern float4 bar(...) 也是外部符号,稍后由动态库解析
  • main 是 fragment shader,输入来自 [[stage_in]]
  • switch(condition(input)) 决定调用哪个外部函数
  • return foo(input)return bar(input) 的实现可以在运行时通过加载不同动态库替换

06:04)把动态库加入对应 stage 的 preloaded libraries 数组后,符号会按数组顺序解析。fragment、vertex、tile 等 stage 都有对应属性。

renderPipeDesc.fragmentPreloadedLibraries = @[cachedLibrary];

关键点:

  • fragmentPreloadedLibraries 对应 fragment stage
  • 数组里的动态库提供 foo()bar() 这类 extern 函数的实现
  • transcript 明确说明,符号按加入数组的顺序解析

函数指针:让 shader 在运行时选择调用目标

09:01)函数指针的第一步,是把可调用函数实例化。为了加速 pipeline 创建,可以把函数编译成 binary 形式。

// Declare a descriptor and set CompileToBinary options

MTLFunctionDescriptor* functionDescriptor = [MTLFunctionDescriptor new];
functionDescriptor.options = MTLFunctionOptionCompileToBinary;

// Backend compile the function

functionDescriptor.name = @"foo";
id<MTLFunction> foo = [library newFunctionWithDescriptor:functionDescriptor
                                                     error:&error];

关键点:

  • MTLFunctionDescriptor 描述要从 library 里取出的函数
  • MTLFunctionOptionCompileToBinary 要求 backend compiler 生成 GPU binary
  • functionDescriptor.name = @"foo" 指定函数名
  • newFunctionWithDescriptor:error: 根据 descriptor 创建 MTLFunction

09:30)创建 pipeline descriptor 时,要告诉 Metal 当前 stage 可能调用哪些函数。AIR 函数和 binary 函数走不同数组。

// Provide a list of functions that the pipeline stage may call

// AIR functions

renderPipeDesc.fragmentLinkedFunctions.functions = @[foo, bar, baz];

// Binary functions

renderPipeDesc.fragmentLinkedFunctions.binaryFunctions = @[foo, bar, baz];

关键点:

  • fragmentLinkedFunctions 绑定 fragment stage 的可调用函数集合
  • functions 放 AIR 形式的函数,compiler 可以在 AIR 层做静态链接和优化
  • binaryFunctions 放已经 backend 编译的函数,用来缩短 pipeline 创建时间
  • transcript 提到,如果 binary 函数有复杂调用链,需要正确设置最大调用栈深度,避免 stack overflow

10:47)pipeline 创建完成后,创建可见函数表,再把函数 handle 放进表中。

// Create visible function table

id<MTLVisibleFunctionTable> visibleFunctionTable =
    [renderPipeline newVisibleFunctionTableWithDescriptor:tableDescriptor
                                                    stage:MTLFunctionStageFragment];

// Create function handles

id<MTLFunctionHandle> fooHandle =
    [renderPipeline functionHandleWithFunction:foo
                                         stage:MTLFunctionStageFragment];

// Insert handles into table

[visibleFunctionTable setFunction:fooHandle
                           atIndex:0];

关键点:

  • newVisibleFunctionTableWithDescriptor:stage: 创建指定 stage 的函数表
  • functionHandleWithFunction:stage: 从 pipeline 中拿到 stage 专用的函数 handle
  • setFunction:atIndex: 把 handle 写入函数表槽位
  • table 和 handle 都绑定到某个 pipeline 与某个 stage

11:21)渲染时,CPU 端绑定函数表,shader 端把函数表当作 buffer 参数使用。

// Bind visible function table objects to each stage

[renderCommandEncoder setFragmentVisibleFunctionTable:visibleFunctionTable
                                        atBufferIndex:0];
// Usage in shader

fragment float4 shaderFunc(FragmentData vo[[stage_in]],
                           visible_function_table<float4(float3)>materials[[buffer(0)]])
{
    // ...

    return materials[materialSelector](coord);
}

关键点:

  • setFragmentVisibleFunctionTable:atBufferIndex: 把函数表绑定到 fragment stage 的 buffer index
  • visible_function_table<float4(float3)> 声明函数表中每个函数的签名
  • materials[[buffer(0)]] 与 CPU 端的 atBufferIndex:0 对应
  • materials[materialSelector](coord) 通过索引选择函数并调用

增量 pipeline:后续添加 binary function

12:20)创建好 pipeline 后,可能又发现需要调用新函数 bat()。重新用完整 descriptor 创建第二个 pipeline 会触发 pipeline 编译。Metal 允许先声明这个 pipeline 未来可以扩展,然后基于旧 pipeline 快速创建新 pipeline。

// Enable incrementally adding binary functions per stage

renderPipeDesc.supportAddingFragmentBinaryFunctions = YES;

// Create render pipeline functions descriptor

MTLRenderPipelineFunctionsDescriptor extraDesc;
extraDesc.fragmentAdditionalBinaryFunctions = @[bat];

// Instantiate render pipeline state

id<MTLRenderPipelineState> renderPipeline2 =
  [renderPipeline1 newRenderPipelineStateWithAdditionalBinaryFunctions:extraDesc
                                                                 error:&error];

关键点:

  • supportAddingFragmentBinaryFunctions = YES 在原始 pipeline descriptor 上声明 fragment stage 可扩展
  • MTLRenderPipelineFunctionsDescriptor 描述新增函数集合
  • fragmentAdditionalBinaryFunctions = @[bat] 只添加新的 binary function
  • newRenderPipelineStateWithAdditionalBinaryFunctions:error: 基于 renderPipeline1 创建包含 bat 的新 pipeline state

Binary Archive:把编译结果存到磁盘

14:15)Binary Archive 在 WWDC20 已经用于 pipeline。WWDC21 增加了把 visible function 和 intersection function 存入 archive 的能力。保存函数时,调用 addFunctionWithDescriptor,并把 function descriptor 和 source library 传进去。

[archive addFunctionWithDescriptor:functionDescriptor
                            library:library
                              error:&error];

functionDescriptor.binaryArchives = @[archive];

id<MTLFunction> foo = [library newFunctionWithDescriptor:functionDescriptor
                                                     error:&error];

关键点:

  • addFunctionWithDescriptor:library:error: 把函数的 binary 编译结果加入 archive
  • functionDescriptor.binaryArchives = @[archive] 让后续函数创建先查 archive
  • newFunctionWithDescriptor:error: 如果在 archive 中找到已编译函数,会直接返回
  • transcript 提到,archive 可以同时存 render、tile、compute pipeline,以及 binary function pointer

14:55)查找规则也影响错误处理。Metal 先查 binary archive 列表;找不到时,再看 CompileToBinary 选项;如果要求 binary 编译,还要看 pipeline option 里的 FailOnBinaryArchiveMiss

functionDescriptor.options = MTLFunctionOptionCompileToBinary;
functionDescriptor.binaryArchives = @[archive];

id<MTLFunction> foo = [library newFunctionWithDescriptor:functionDescriptor
                                                     error:&error];

关键点:

  • binaryArchives 是查找 binary function 的 archive 列表
  • MTLFunctionOptionCompileToBinary 表示 archive miss 后仍需要 binary 形式
  • transcript 说明,FailOnBinaryArchiveMiss 会决定 archive miss 时是运行时编译,还是返回 nil

私有链接函数:给优化留空间

17:45linkedFunctions 里的 functionsprivateFunctions 都在 AIR 层静态链接。差别在可见性。privateFunctions 不能生成 function handle,也不能放进 visible function table。它们是 pipeline 内部实现,compiler 可以更充分地优化。

renderPipeDesc.fragmentLinkedFunctions.privateFunctions = @[foo, bar];

关键点:

  • privateFunctions 适合只给当前 pipeline 内部调用的外部函数
  • 它们在 AIR 层静态链接
  • 不能为 private function 创建 function handle
  • transcript 说明,这个能力在 macOS Monterey 和 iOS 15 的所有设备可用,因为它工作在 AIR 层

Function Stitching:用图生成运行时函数

19:03)函数拼接从计算图生成函数。图是有向无环图(Directed Acyclic Graph)。输入节点表示生成函数的参数,函数节点表示函数调用。数据边表示数据流,控制边表示调用顺序。

[[stitchable]] int FunctionA(device int*, int) {…}
[[stitchable]] int FunctionC(int, int) {…}

[[stitchable]]
int ResultFunction(device int* Input0,
                   int Input1,
                   int Input2)
{
  int N0 = FunctionA(Input0, Input1);
  int N1 = FunctionA(Input0, Input2);
  int N2 = FunctionC(N0, N1);
  return N2;
}

关键点:

  • [[stitchable]] 标记函数可以被 function stitching API 使用
  • FunctionAFunctionC 是预先存在于 library 中的可拼接函数
  • ResultFunction 展示拼接结果等价于怎样的 Metal 函数
  • transcript 说明,真正的 stitching 会直接在 AIR 层生成 library,跳过 Metal frontend

21:32)Objective-C 端先创建输入节点,再创建函数节点,最后创建 graph。

// Create input nodes

inputs[0] = [[MTLFunctionStitchingInputNode alloc] initWithArgumentIndex:0];

// Create function nodes

n0 = [[MTLFunctionStitchingFunctionNode alloc] initWithName:@"FunctionA"
                                                  arguments:@[inputs[0], inputs[1]]
                                        controlDependencies:@[]];
n1 = [[MTLFunctionStitchingFunctionNode alloc] initWithName:@"FunctionA"
                                                  arguments:@[inputs[0], inputs[2]]
                                        controlDependencies:@[]];
n2 = [[MTLFunctionStitchingFunctionNode alloc] initWithName:@"FunctionC"
                                                  arguments:@[n0, n1]
                                        controlDependencies:@[]];

// Create graph

graph = [[MTLFunctionStitchingGraph alloc] initWithFunctionName:@"ResultFunction"
                                                          nodes:@[n0, n1]
                                                     outputNode:n2
                                                     attributes:@[]];

关键点:

  • MTLFunctionStitchingInputNode 用 argument index 映射生成函数的参数
  • MTLFunctionStitchingFunctionNode 用函数名和参数列表表示一次函数调用
  • controlDependencies:@[] 表示这里没有额外的执行顺序约束
  • MTLFunctionStitchingGraph 指定生成函数名、节点列表、输出节点和属性

22:18)有了 graph 后,创建 stitched library descriptor,把可拼接函数和 graph 放进去,再从新 library 里取出生成函数。

// Configure stitched library descriptor

MTLStitchedLibraryDescriptor* descriptor = [MTLStitchedLibraryDescriptor new];

descriptor.functions      = @[stitchableFunctions];
descriptor.functionGraphs = @[graph];

// Create stitched function

id<MTLLibrary> lib = [device newLibraryWithDescriptor:descriptor
                                                error:&error];

id<MTLFunction> stitchedFunction = [lib newFunctionWithName:@"ResultFunction"];

关键点:

  • MTLStitchedLibraryDescriptor 是创建 stitched library 的入口
  • functions 放 graph 中可以调用的 stitchable functions
  • functionGraphs 放要生成的函数图
  • newLibraryWithDescriptor:error: 根据 descriptor 生成 library
  • newFunctionWithName:@"ResultFunction" 取出 stitched function,它可以继续用于 pipeline、function pointer 或其他 stitching graph

核心启发

  1. 做什么:为 3D 编辑器做运行时材质系统。 为什么值得做:函数指针让 shader 通过 visible_function_table 调用材质函数,切换材质不需要重建完整 pipeline。 怎么开始:把不同材质函数编译成 MTLFunction,通过 newVisibleFunctionTableWithDescriptor:stage: 创建表,用 setFunction:atIndex: 写入 handle,在 shader 中用 materials[materialSelector](coord) 调用。

  2. 做什么:为滤镜 App 做用户可组合的效果链。 为什么值得做:function stitching 可以把预编译的 [[stitchable]] 函数按图组合,避免运行时拼 Metal source 字符串。 怎么开始:把基础滤镜写成 [[stitchable]] 函数,用 MTLFunctionStitchingInputNodeMTLFunctionStitchingFunctionNode 组图,再用 MTLStitchedLibraryDescriptor 生成函数。

  3. 做什么:为游戏引擎做 shader 工具库缓存。 为什么值得做:动态库可以把 helper functions 编译成独立 GPU binary,render、tile、compute pipeline 共用。 怎么开始:把工具函数编成 AIR,调用 newDynamicLibrary 生成动态库,用 serializeToURL 落盘,启动时通过 newDynamicLibraryWithURL 加载,并放入对应 stage 的 preloaded libraries。

  4. 做什么:为首次进入场景减少卡顿。 为什么值得做:Binary Archive 可以保存 pipeline 和 binary function pointer 的编译结果,下一次启动减少 shader 编译时间和编译内存成本。 怎么开始:创建 MTLBinaryArchive,用 addFunctionWithDescriptor:library:error: 预填函数,把 archive 写入磁盘;加载时设置 functionDescriptor.binaryArchives 后再创建函数。

  5. 做什么:为插件式渲染器支持用户提供 shader 函数。 为什么值得做:动态库允许用户提供已编译实现,主 shader 只保留 extern 声明;函数指针允许 pipeline 调用编译时未见过的函数。 怎么开始:约定函数签名,主 shader 用 externvisible_function_table 声明调用点,加载用户动态库或 binary function 后绑定到 pipeline。

关联 Session

评论

GitHub Issues · utterances