工程文章

云端 Mac 的 Metal 着色器离线编译与验收

云端 Mac 的 Metal 着色器离线编译与验收

图形功能在开发机上运行正常,却在 CI 归档末尾才报 Metal 编译错误,通常不是“机器性能不够”,而是着色器没有被当成独立构建输入管理。更稳妥的做法,是在云端 Mac 上先用当前 Xcode 工具链离线生成真机与模拟器版本的 metallib,再进入完整工程构建。这样语法错误、SDK 选错和函数缺失会更早暴露,失败日志也更短。

为什么把着色器拆成独立门禁

Xcode 会自动处理加入目标的 .metal 文件,但错误往往埋在包含依赖解析、资源复制和签名步骤的长日志里。独立门禁不替代 Xcode 构建,而是在它之前回答三个问题:

  1. 所有着色器能否由当前工具链编译;
  2. 真机与模拟器目标能否分别生成库;
  3. 应用实际引用的函数名能否从库中加载。

不要把开发机生成的 metallib 直接复制到长期使用的产物目录。工具链、SDK 或编译参数变化后,旧文件可能继续存在,却已不能代表当前源码。

建议让仓库只提交 .metal 源码和编译脚本,把 .air.metallib 放入可清理的构建目录。若团队需要缓存,缓存键至少应包含源码摘要、Xcode 路径、SDK 版本和目标平台。

固定本次构建的工具链

云端 Mac 可能安装多个 Xcode。脚本不应依赖图形界面中上次选择的版本,而应显式检查 DEVELOPER_DIR。路径由节点实际安装情况决定,不要在多个任务之间共用未经确认的值。

set -euo pipefail

: "${DEVELOPER_DIR:?DEVELOPER_DIR is required}"

xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
xcrun --sdk iphonesimulator --show-sdk-version
xcrun --sdk iphoneos --find metal
xcrun --sdk iphoneos metal --version

mkdir -p build/metal/metadata
{
  echo "developer_dir=$DEVELOPER_DIR"
  xcodebuild -version
  echo "iphoneos=$(xcrun --sdk iphoneos --show-sdk-version)"
  echo "iphonesimulator=$(xcrun --sdk iphonesimulator --show-sdk-version)"
  xcrun --sdk iphoneos metal --version
} > build/metal/metadata/toolchain.txt

toolchain.txt 应随失败日志归档。它能快速区分“源码回归”和“执行器切换了 Xcode”这两类问题。不要只记录 xcodebuild -version,因为实际调用的 SDK 同样影响结果。

分别编译真机与模拟器产物

先把每个源码编译成独立 .air,再合并为库。逐文件编译的好处是错误能直接定位到文件,而不是只得到一次笼统的链接失败。

set -euo pipefail

sources=(Shaders/*.metal)
if [ ! -e "${sources[0]}" ]; then
  echo "No Metal source files found" >&2
  exit 1
fi

for sdk in iphoneos iphonesimulator; do
  out="build/metal/$sdk"
  rm -rf "$out"
  mkdir -p "$out/air"

  for src in "${sources[@]}"; do
    name="$(basename "$src" .metal)"
    xcrun --sdk "$sdk" metal -c "$src" \
      -o "$out/air/$name.air"
  done

  xcrun --sdk "$sdk" metallib "$out"/air/*.air \
    -o "$out/AppShaders.metallib"

  test -s "$out/AppShaders.metallib"
  shasum -a 256 "$out/AppShaders.metallib" \
    > "$out/AppShaders.metallib.sha256"
done

两套产物必须分目录保存,不能让后执行的任务覆盖前一套。若源码通过 C/C++ 预处理宏区分平台,应把宏参数集中写进脚本,并确保 Xcode Build Phase 使用同一组定义。

检查对象 真机目标 模拟器目标
SDK iphoneos iphonesimulator
中间文件 独立 .air 独立 .air
输出库 单独归档 单独归档
最终验收 真机或正式归档 模拟器冒烟测试

用加载测试验证函数契约

文件非空只能证明编译器产生了输出,不能证明应用查找的函数仍然存在。重命名 kernel 或 fragment 函数后,Swift 代码中的字符串可能没有同步修改。应在测试目标中加载库并查询关键入口。

import Metal
import XCTest

final class ShaderLibraryTests: XCTestCase {
    func testRequiredFunctionsExist() throws {
        let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
        let url = try XCTUnwrap(
            Bundle(for: Self.self).url(
                forResource: "AppShaders",
                withExtension: "metallib"
            )
        )
        let library = try device.makeLibrary(URL: url)

        for name in ["imageVertex", "imageFragment", "toneMapKernel"] {
            XCTAssertNotNil(
                library.makeFunction(name: name),
                "Missing Metal function: \(name)"
            )
        }
    }
}

函数清单应来自应用真正创建管线时使用的名称。测试包还要确认复制的是 iphonesimulator 产物,而不是工作区里碰巧残留的真机文件。最低限度的冒烟测试是创建 MTLDevice、加载库并查询函数;涉及纹理格式或线程组约束时,再增加管线创建测试。

失败时保留什么证据

排查 Metal CI 故障时,最有价值的不是完整工作目录,而是少量可对照信息:

  • DEVELOPER_DIR、Xcode 与两个 SDK 的版本;
  • 实际执行的编译命令和标准错误;
  • 每个 .metal 文件的源码摘要;
  • 两套 metallib 的大小与 SHA-256;
  • 加载失败的函数名、测试目标和运行目标;
  • 本次任务使用的提交标识。

源码摘要可以用 find Shaders -name '*.metal' -print0 | sort -z | xargs -0 shasum -a 256 生成。产物哈希改变不应单独触发失败,因为工具链升级也可能改变二进制;正确策略是先比较工具链指纹,再以重新编译、加载和管线创建结果作为门禁。

在 MacMLab 的独享物理节点执行这类任务时,同一个工作区仍可能被连续作业污染。每次构建前清空目标输出目录,任务结束后只归档元数据、日志和最终库。这样既能避免旧 .air 混入新库,也能让下一次失败具备可复现的起点。

常见问题

Xcode 已经会编译 Metal 文件,为什么还要单独执行离线编译?

独立编译能在完整归档前暴露语法、SDK 和目标差异,并生成更短、更容易定位的日志;正式构建仍应保留 Xcode 自身的着色器编译步骤。

真机与模拟器可以共用同一个 metallib 文件吗?

不应直接共用。应分别通过 iphoneos 和 iphonesimulator SDK 编译,并在对应运行目标上执行加载与函数查找测试。

Metal 产物哈希变化是否一定表示构建异常?

不一定。工具链或 SDK 变化可能改变二进制内容,应先比对 DEVELOPER_DIR、SDK 版本和编译器版本,再结合冒烟测试判断。

独享物理节点

把开发与构建任务放到独享云端 Mac

按任务选择机型、节点区域和租用周期。每份实例对应独享物理机,是非虚拟机环境。

选择云端 Mac 方案