Highlight
Metal 通过 function constants 消除 uber shader 的动态分支、用异步编译保持材质编辑响应、用动态链接减少编译时间、用 occupancy hints 调优计算内核,让 Blender 3D 的材质渲染从 58ms 降到 12.5ms。
核心内容
Uber Shader 的两难
数字内容创作应用和游戏引擎中,材质系统有两个矛盾的目标:
- 编辑响应:拖动滑块时 viewport 即时更新,不能卡顿
- 渲染性能:实时交互和最终帧渲染都要快
Uber shader 是一个包含所有可能材质特性的巨型着色器。编辑时只需要更新材质参数缓冲区,无需重新编译,响应很快。但因为要处理所有分支,运行时性能很差。
Function constants 解决了这个矛盾。它在编译时把材质参数变成常量,编译器据此做死代码消除和常量折叠,生成最优的特化版本。
(02:05)
用 Function Constants 特化材质
Uber shader 的问题代码:
fragment FragOut frag_material_main(device Material &material [[buffer(0)]]) {
if (material.is_glossy) {
material_glossy(material);
}
if (material.has_shadows) {
light_shadows(material);
}
if (material.has_reflections) {
trace_reflections(material);
}
if (material.is_volumetric) {
output_volume_parameters(material);
}
return output_material();
}
这些 if 在运行时判断,每次都要读取材质缓冲区,产生大量分支。
改用 function constants:
constant bool IsGlossy [[function_constant(0)]];
constant bool HasShadows [[function_constant(1)]];
constant bool HasReflections [[function_constant(2)]];
constant bool IsVolumetric [[function_constant(3)]];
fragment FragOut frag_material_main(device Material &material [[buffer(0)]]) {
if (IsGlossy) {
material_glossy(material);
}
if (HasShadows) {
light_shadows(material);
}
if (HasReflections) {
trace_reflections(material);
}
if (IsVolumetric) {
output_volume_parameters(material);
}
return output_material();
}
关键点:
[[function_constant(N)]]声明编译时常量- Metal 编译器把常量布尔值折叠,值为 false 的分支直接删除
- 不需要读取材质缓冲区来判断特性开关
- 控制流大幅简化,内存读取和分支都被移除
(03:45)
常量折叠优化
不变化的静态参数也可以用 function constants:
constant float4 MaterialColor [[function_constant(0)]];
constant float4 MaterialWeight [[function_constant(1)]];
constant float4 SheenColor [[function_constant(2)]];
constant float4 SheenFactor [[function_constant(3)]];
void material_glossy(const constant Material& material) {
float4 light = glossy_eval(MaterialColor, MaterialWeight);
float4 sheen = sheen_eval(SheenColor, SheenFactor);
glossy_output_write(light, sheen, material.blend_factor);
}
编译器直接把这些值内联到指令中,不需要从缓冲区读取。
(04:58)
在主机端创建特化管线
// 定义材质参数结构
struct MaterialParameter {
NSString* name;
MTLDataType type;
void* value_ptr;
};
// 创建参数实例
Material material = {true, {1.0f, 0.0f, 0.0f, 1.0f}};
MaterialParameter is_glossy{@"IsGlossy", MTLDataTypeBool, &material.is_glossy};
MaterialParameter mat_color{@"MaterialColor", MTLDataTypeFloat4, &material.color};
MaterialParameter shader_parameters[2] = {is_glossy, mat_color};
// 填充 function constant values
MTLFunctionConstantValues* values = [MTLFunctionConstantValues new];
for (const MaterialParameter& parameter : shader_parameters) {
[values setConstantValue:parameter.value_ptr
type:parameter.type
withName:parameter.name];
}
// 创建管线描述符,使用带 function constants 的片段函数
MTLRenderPipelineDescriptor *dsc = [MTLRenderPipelineDescriptor new];
dsc.fragmentFunction = [shader_library newFunctionWithName:@"frag_material_main"
constantValues:values
error:&error];
// 创建管线状态对象
id<MTLRenderPipelineState> pso = [device newRenderPipelineStateWithDescriptor:dsc
error:&error];
关键点:
newFunctionWithName:constantValues:error:创建带特化的函数MTLFunctionConstantValues通过名称设置常量值- 创建的 PSO 是针对这组材质参数的最优版本
(05:21)
详细内容
GPU Debugger 验证优化效果
Uber shader 的 GPU Debugger 数据显示:
- ALU 指令数很高
- 大量寄存器溢出(spill)
- 内存等待时间很长
特化后的数据:
- ALU 指令和 spill 立即减少(死代码消除和常量折叠)
- 内存等待时间显著降低
Timeline view 中,uber shader 渲染材质 Pass 需要 58ms,特化版本只需 12.5ms。
(06:26)
异步编译保持响应
特化需要运行时编译,如果阻塞等待会造成卡顿。Metal 的异步 PSO 创建 API 解决了这个问题:
// 异步创建 shader library
[device newLibraryWithSource:source
options:options
completionHandler:^(id<MTLLibrary> library, NSError* error) {
// 库编译完成
}];
// 异步创建渲染管线状态
[device newRenderPipelineStateWithDescriptor:descriptor
completionHandler:^(id<MTLRenderPipelineState> pso, NSError* error) {
// PSO 编译完成,切换到特化版本
}];
工作流程:
- 默认使用 uber shader 渲染
- 同时触发特化版本的异步编译
- 编译完成后切换到特化版本
- 材质参数变化时,使缓存的特化版本失效,回退到 uber shader,触发新的异步编译
(07:52)
最大化并发编译
macOS 13.3 新增 shouldMaximizeConcurrentCompilation 属性:
// 启用最大并发编译
device.shouldMaximizeConcurrentCompilation = YES;
设置后,Metal 编译器会充分利用所有 CPU 核心。这对同时编辑多个材质的场景特别有用,特化版本能更快就绪。
(09:16)
动态链接加速编译
复杂材质的特化版本编译时间可能很长。动态链接可以把通用函数预编译成库,减少运行时编译量。
符号可见性控制:
// 对外可见的函数
__attribute__((visibility("default")))
void matrix_mul();
// 内部使用的函数
__attribute__((visibility("hidden")))
void matrix_mul_internal();
检查设备支持:
// 渲染管线动态库
BOOL supportsRenderDylib = device.supportsRenderDynamicLibraries;
// 计算管线动态库
BOOL supportsComputeDylib = device.supportsDynamicLibraries;
Apple6 及以上 GPU family 支持渲染管线动态库。Apple6 及以上和大多数 Mac2 GPU family 支持计算管线动态库。
创建动态库:
// 从现有 Metal 库创建
id<MTLDynamicLibrary> dylib = [device newDynamicLibrary:library error:&error];
// 从文件 URL 创建
id<MTLDynamicLibrary> dylib = [device newDynamicLibraryWithURL:url error:&error];
在管线中链接:
MTLRenderPipelineDescriptor* dsc = [MTLRenderPipelineDescriptor new];
dsc.vertexPreloadedLibraries = @[dylib_Math, dylib_Shadows];
dsc.fragmentPreloadedLibraries = @[dylib_Math, dylib_Shadows];
// 或在编译选项中指定
MTLCompileOptions* options = [MTLCompileOptions new];
options.libraries = @[dylib_Math, dylib_Shadows];
[device newLibraryWithSource:programString
options:options
error:&error];
关键点:
- 动态库可以离线预编译,运行时完全避免编译
- 把大型工具函数拆分到单独的动态库中
- 主着色器编译时只需链接,大幅缩短时间
(10:58)
计算内核 Occupancy 调优
每个 GPU 工作负载都有性能甜点,需要通过实验找到。Metal 提供 API 来指定期望的 GPU 占用率:
// 计算管线描述符
@interface MTLComputePipelineDescriptor : NSObject
@property (readwrite, nonatomic) NSUInteger maxTotalThreadsPerThreadgroup;
@end
// 编译选项(macOS 13.3 / iOS 16.4 新增)
@interface MTLCompileOptions : NSObject
@property (readwrite, nonatomic) NSUInteger maxTotalThreadsPerThreadgroup;
@end
动态库的 occupancy 匹配:
MTLCompileOptions* options = [MTLCompileOptions new];
options.libraryType = MTLLibraryTypeDynamic;
options.installName = @"executable_path/dylib_Math.metallib";
if (@available(macOS 13.3, *)) {
options.maxTotalThreadsPerThreadgroup = 768;
}
id<MTLLibrary> lib = [device newLibraryWithSource:programString
options:options
error:&error];
id<MTLDynamicLibrary> dynamicLib = [device newDynamicLibrary:lib error:&error];
关键点:
maxTotalThreadsPerThreadgroup值越高,编译器越倾向于高占用率- 需要在管线描述符和动态库编译选项中设置相同的值
- 每个工作负载和设备的最优值不同,需要实验
- 最优值不一定是 GPU 支持的最大线程数
Blender Cycles 着色和相交计算内核的性能曲线显示,存在一个明显的性能甜点。在那个点上,spill 略有增加,但整体占用率提升带来了显著更好的内核性能。
(13:45)
核心启发
-
做什么:用 function constants 特化复杂材质着色器
- 为什么值得做:Blender 3D 实测从 58ms 降到 12.5ms,ALU 指令、spill 和内存等待都大幅减少
- 怎么开始:把材质特性开关声明为
[[function_constant(N)]],在创建 PSO 时通过MTLFunctionConstantValues传入值
-
做什么:实现异步材质编译 workflow
- 为什么值得做:编辑材质时不会卡顿,uber shader 保底,特化版本在后台编译完成后无缝切换
- 怎么开始:用带 completion handler 的异步 API 创建 library 和 PSO,编译完成后替换渲染使用的管线
-
做什么:用动态链接拆分通用函数库
- 为什么值得做:把数学、光照等通用函数预编译成动态库,主着色器编译时只需链接,大幅缩短时间
- 怎么开始:按功能分组创建动态库,设置
visibility("default"),在管线描述符的preloadedLibraries中引用
-
做什么:调优计算内核的 maxTotalThreadsPerThreadgroup
- 为什么值得做:不需要改代码或算法,只改一个参数就能找到性能甜点
- 怎么开始:为内核设置不同的
maxTotalThreadsPerThreadgroup值,用 GPU Debugger 测量执行时间,找到最优值
关联 Session
- Bring your game to Mac, Part 1: Make a game plan — 用 Game Porting Toolkit 评估 Windows 游戏在 Mac 上的可行性
- Bring your game to Mac, Part 2: Compile your shaders — 使用 Metal Shader Converter 将 HLSL 着色器转换为 Metal
- Bring your game to Mac, Part 3: Render with Metal — 在 Metal 中实现高性能渲染
评论
GitHub Issues · utterances