BNNS Graph 新特性
What's new in BNNS Graph
2025年6月9日
一句话判断
BNNS Graph Builder API 让你用纯 Swift 写推理图——不再需要维护单独的 PyTorch/CoreML 文件,编译期就能做类型检查,而且 FP16 比 FP32 快了一大截。这对实时音频和图像预处理场景是重大利好。
这场 Session 讲了什么
去年 BNNSGraph 首次亮相,提供了一种从 CoreML 包加载图并执行推理的文件式 API。今年的 BNNSGraphBuilder 是一个全新的 Swift DSL,让你直接在 Swift 代码里定义计算图——从输入参数声明、操作符调用到输出返回,全在一个 makeContext 闭包里完成。
核心优势有几点:第一,图在 Swift 编译期就做类型检查——混合了浮点和整数张量必须显式 cast,否则编译不过;第二,可以共享 Swift 运行时已知的值(比如张量形状),避免运行时动态形状带来的性能损失;第三,中间张量可以查询 shape 和数据类型,方便调试;第四,全面支持 FP16,在实际测试中性能显著优于 FP32。
API 提供了丰富的操作原生:矩阵乘法、卷积、归约(mean/sum)、gather/scatter、padding/reshape/transpose,加上 Swift 风格的算术运算符(+, -, *, /)、比较运算符(>, <, ==)和逻辑运算符。切片操作用 Swift 下标语法实现,通过 SliceRange 结构体指定,底层零拷贝。
Session 用三个 demo 展示了实际用法:图像二值化预处理、ML 模型输出的 softmax + topK 后处理、以及去年 Bitcrusher 音频效果从 PyTorch 迁移到 Swift 的对比。
值得深挖的点
编译期类型检查的价值被低估了
很多人会觉得”编译期检查”只是锦上添花,但在 BNNS Graph Builder 的场景下,这是实打实的安全网。比如你想做 float 的幂运算但指数是整数——如果你忘了 cast,makeContext 直接编译不过,而不是在运行时默默出错或者返回 NaN。对于部署在用户设备上的推理代码,这种保证非常有价值。对比 Python/PyTorch 的动态类型,你在生产环境才能发现的类型错误,在 Swift 里 IDE 阶段就拦住了。
切片的零拷贝语义
BNNS Graph Builder 的切片底层是引用而非复制。这意味着你从 4000x3000 的图像里裁一个 640x640 的区域,不会分配新内存也不会拷贝像素——只是创建了一个指向原始数据子集的视图。对于需要对同一张图做多区域预处理再送进模型的场景,这能省掉大量内存和时间。
vImage 的 withBNNSTensor 方法也值得重点关注——它直接从 vImage PixelBuffer 创建临时 BNNSTensor,共享内存和属性(尺寸、通道数),免去了手动构造张量的步骤。
makeContext 闭包里可以引用外部 Swift 值
闭包不是完全封闭的——你可以在闭包外面定义一个变量(比如 topK 的 k 参数),然后在 makeContext 里直接引用它。这意味着你的推理图可以部分参数化,而不需要每次都重新构建图。对于需要根据用户输入调整预处理策略的场景很实用。
代码片段
用 Swift 写图像裁剪图
// 从源图像中心裁剪 640x640 区域
let source: vImage.PixelBuffer = /* 源图像 */
let destination: vImage.PixelBuffer = /* 裁剪目标 */
let context = try BNNSGraph.makeContext { builder in
let input = builder.argument(name: "source",
shape: [-1, -1, 3]) // -1 表示尺寸运行时确定
let marginH = (input.shape[0] - 640) / 2
let marginW = (input.shape[1] - 640) / 2
let cropped = input[
SliceRange(marginH..<(-marginH)),
SliceRange(marginW..<(-marginW)),
.fillAll // 保留所有通道
]
return [cropped]
}
// vImage PixelBuffer 直接转换为 BNNSTensor
source.withBNNSTensor { srcTensor in
destination.withBNNSTensor { dstTensor in
try context.execute(inputs: [srcTensor], outputs: [dstTensor])
}
}
坑:SliceRange 的负索引表示”从末尾减去该值”,和 Python 的 [-margin:] 语义类似,但写法是 -margin 而不是 ~margin。
图像二值化预处理
let thresholdContext = try BNNSGraph.makeContext { builder in
let input = builder.argument(name: "image",
shape: [-1, -1], dataType: .float16)
let avg = input.mean() // 全图像素均值
let mask = input .> avg // 逐元素比较,结果是 Bool 张量
let result = mask.cast(to: .float16) // Bool -> Float16
return [result]
}
坑:比较运算符返回的是 Bool 张量,必须显式 cast 才能用于后续算术运算,否则编译失败——这就是类型检查在起作用。
PyTorch vs Swift 的直观对比
// PyTorch (去年的方案)
output = torch.tanh(input * gain) * level
// Swift GraphBuilder (今年)
let context = try BNNSGraph.makeContext { builder in
let input = builder.argument(name: "input", shape: [256])
let gain = builder.argument(name: "gain", shape: [256])
let level = builder.argument(name: "level", shape: [256])
let result = (input * gain).tanh() * level
return [result]
}
结构几乎一模一样,但 Swift 版本有类型检查、不需要单独的 Python 文件、而且支持 FP16。
最佳实践
对于已有 PyTorch 模型的项目,仍然推荐用文件式 API(CoreML 包 -> mlmodelc -> BNNSGraph),这是最成熟的路径。BNNSGraphBuilder 适合两种场景:一是小模型或预处理/后处理图谱——你不想为几行操作维护一个单独的 Python 文件;二是需要在运行时根据参数动态调整图结构的场景。
FP16 应该是默认选择,除非你有明确的理由需要 FP32 的精度。Session 的实测数据表明 FP16 在 BNNS 上比 FP32 快显著幅度,而且对音频和图像处理任务来说精度损失通常可忽略。
调试技巧:用 print 打印中间张量的 shape 确认图结构是否符合预期。BNNSGraphBuilder 的切片和类型检查已经帮你排除了大部分错误,但张量维度不匹配仍然需要自己注意。
还有什么值得关注
- BNNSGraph 的优化(层融合、拷贝消除、内存共享、权重重排)对 GraphBuilder 生成的图同样生效——不需要写任何额外代码。
- makeContext 只在初始化时调用一次,之后反复执行。上下文创建有开销,不要在热路径里重复创建。
- 支持的操作集合和文件式 API 完全一致——如果你不确定某个操作是否支持,查 BNNSGraph C API 的文档就行。
- vImage PixelBuffer 的 withBNNSTensor 是临时方法,闭包结束 BNNSTensor 就失效——不要在闭包外引用它。