Highlight
BNNSGraph debuted last year with a file-based API that loaded a graph from a CoreML package and ran inference. This year’s BNNSGraphBuilder is a brand-new Swift DSL. You define the compute graph directly in Swift code — argument declarations, operator calls, output returns — all inside a single
makeContextclosure.
Core content
Last year’s Bitcrusher demo showed how BNNSGraph replaced the chore of writing discrete layers by hand, but you still had to walk the PyTorch → CoreML package → mlmodelc pipeline. For an audio effect with only a dozen tensor ops, maintaining a separate Python file and building a CoreML package costs more than it saves. For pre- and post-processing graphs, developers kept bouncing between writing Swift code that called BNNS primitives one by one and detouring through PyTorch to compile a model.
This year Apple flattens the whole flow. BNNSGraphBuilder lets you define the graph inside a Swift closure: declare an argument, call operators, return the output tensors. One BNNSGraph.makeContext call gives you an executable context. The code lives in the same source file as the rest of your Swift code. Type checks happen at compile time. Once a tensor shape is known at Swift runtime, you can pass it into the graph and get the speed of static shapes. Combined with BNNSGraph’s existing layer fusion, copy elision, and weight repacking, small models and pre/post-processing graphs no longer need PyTorch as a middleman.
Detailed content
The entry point of the API is the new type method BNNSGraph.makeContext. The builder closure parameter is where you declare inputs, assemble operators, and return outputs. The snippet below is the smallest runnable example from the session at 8:31 (8:31). It computes the element-wise product of two 8-element vectors and takes the mean:
let context = try BNNSGraph.makeContext { builder in
let x = builder.argument(name: "x",
dataType: Float.self,
shape: [8])
let y = builder.argument(name: "y",
dataType: Float.self,
shape: [8])
let product = x * y
let mean = product.mean(axes: [0], keepDimensions: true)
// Prints "shape: [1] | stride: [1]".
print("mean", mean)
return [product, mean]
}
var args = context.argumentNames().map { name in
context.tensor(argument: name, fillKnownDynamicShapes: false)!
}
args[0].allocate(as: Float.self, count: 8) // product
args[1].allocate(as: Float.self, count: 1) // mean
args[2].allocate(initializingFrom: [1, 2, 3, 4, 5, 6, 7, 8] as [Float])
args[3].allocate(initializingFrom: [8, 7, 6, 5, 4, 3, 2, 1] as [Float])
try context.executeFunction(arguments: &args)
Key points:
BNNSGraph.makeContext { builder in ... }compiles a Swift closure into a reusable context. You usually build it once at app launch.builder.argument(name:dataType:shape:)declares an input tensor.shape: [8]is a static shape, so no runtime inference is needed.x * yuses a Swift operator to trigger element-wise multiplication, equivalent to BNNS’s element-wise multiply primitive.product.mean(axes:keepDimensions:)calls a reduction operator.keepDimensions: truekeeps the reduced axis and yields a[1]shape.print("mean", mean)prints the shape and stride of an intermediate tensor at compile/build time, which helps debugging.return [product, mean]decides the output tensors.context.argumentNames()lists outputs first, inputs second.executeFunction(arguments:)actually runs the graph and writes results into the output tensors you allocated.
The type system rejects implicit data-type mixing at compile time. The strongly typed example at 12:04 (12:04) shows the form bases.pow(y: exponents.cast(to: Float16.self)) — a Float16 base and an Int32 exponent must be cast explicitly, or makeContext fails to compile. The boolean tensor returned by the comparison mask0 .< mask1 likewise needs cast(to: Float16.self) before it can multiply with an FP16 tensor.
Slicing uses Swift subscript syntax (14:15). BNNSGraph.Builder.SliceRange(startIndex:endIndex:) supports negative indices that count from the tail. SliceRange.fillAll keeps the whole dimension. Underneath, BNNSGraph treats the slice as a reference to the original data — zero copy, zero extra allocation.
The post-processing example (19:04) combines softmax(axis:) with topK(_:axis:findLargest:). The latter returns a tuple of values and indices — the typical decode flow for a classification model.
Migrating Bitcrusher is the clearest before/after (21:03). The old PyTorch version inherits nn.Module, defines forward, and runs through coremltools. The Swift version calls source * saturationGain, destination.tanh(), destination.round() directly on the tensors. The mapping to PyTorch is nearly one-to-one, but everything lives in a single Swift file. Switch precision by changing BITCRUSHER_PRECISION from Float32 to Float16 (22:34). The session measured FP16 as faster than FP32.
Core takeaways
-
What to do: Move the pre/post-processing of an ML model from Python to Swift and rewrite it with BNNSGraphBuilder. Why it pays off: Pre-processing (cropping, normalization, thresholding) and post-processing (softmax, topK, NMS) usually involve only a dozen operators, yet they sit in a separate Python project. Once moved into Swift, they live alongside your business code. Compile-time type checks catch a lot of shape bugs. How to start: Pick a tiny pre-processing flow (say, center-crop plus normalize), rewrite it with
BNNSGraph.makeContext, and verify the numbers against the original Python output. -
What to do: When you build a custom Audio Unit for Logic Pro or GarageBand, write the DSP graph directly in Swift. Why it pays off: BNNS targets the CPU, low latency, and predictable memory and threading — exactly what real-time audio needs. Once an effect like Bitcrusher is expressed entirely in BNNSGraphBuilder, you no longer have to maintain a PyTorch project. How to start: Translate your existing saturation, quantization, and dry-wet mix formulas into builder ops like
*,tanh(),round(). DefaultBITCRUSHER_PRECISIONtoFloat16. -
What to do: Use BNNSGraphBuilder instead of the CoreML package path for small-model scenarios. Why it pays off: The CoreML package path is mature and solid, but it requires the Python toolchain. When the model has only a few layers and parameters depend on runtime variables, the file-based API becomes a burden. The Swift DSL can build a static-shape graph at runtime from values you already know, which beats flexible-shape performance. How to start: Debug shapes with
print(mean)and similar prints on intermediate tensors. Once they look right, run end-to-end inference withcontext.executeFunction(arguments:).
Related sessions
- Bring advanced speech-to-text to your app with SpeechAnalyzer — The new SpeechAnalyzer API for end-to-end speech-to-text.
- Code-along: Bring on-device AI to your app using the Foundation Models framework — Build generative AI features in your SwiftUI app with Foundation Models.
- Deep dive into the Foundation Models framework — A deep dive into Foundation Models: guided generation, tool use, and streaming output.
- Explore large language models on Apple silicon with MLX — Explore large language models on Apple silicon with MLX.
- Get started with MLX for Apple silicon — Getting started with MLX: high-performance machine learning on Apple silicon.
Comments
GitHub Issues · utterances