Swift 6 On-Device Inference: MLX GPU vs. Core ML ANE
Meta description: Architect Swift 6 actors for MLX GPU and Core ML ANE to achieve sub-50ms first-token latency on Apple Silicon. Benchmarks and routing logic included.
TL;DR
Swift 6’s strict actor isolation is a gift and a trap. Wire it wrong and your inference pipeline serializes itself into a latency cliff. Wire it right — with MLX handling GPU compute on a dedicated actor and Core ML’s ANE scheduler running on its own isolated context — and you get sub-50ms first-token latency on M-series chips for quantized 3B models. The ANE wins on sustained throughput for supported operator sets; the GPU wins on batch flexibility and models that spill past 16GB unified memory budget.
The problem most teams hit on day one
Get Swift 6 actor isolation wrong and your on-device inference pipeline serializes itself into a latency cliff. Get it right and you hit sub-50ms first-token latency on M-series chips for quantized 3B models. Most teams discover this the hard way because they treat inference like a network call — async/await, fire and forget, handle on the main actor. That mental model works until your model is 2GB and the ANE scheduler starts competing with Core Animation for memory bandwidth.
Swift 6 made data races a compile error, which forces an architectural conversation that should have happened earlier. You cannot share an MLModel instance across actor boundaries without explicit sendability guarantees. The compiler will not let you ignore this.
Let me walk you through the architecture that actually works in production.
Actor topology: separating concerns at the boundary
One actor per compute backend. Zero shared mutable state.
// MLX GPU actor — owns the model graph and KV cache
@globalActor
actor MLXInferenceActor {
static let shared = MLXInferenceActor()
private var model: LLMModel?
private var kvCache: KVCache?
func load(_ config: ModelConfig) async throws {
model = try await LLMModel.load(config)
kvCache = KVCache(capacity: config.contextLength)
}
func generate(prompt: MLXArray, maxTokens: Int) -> AsyncStream<Token> {
AsyncStream { continuation in
Task {
guard let model else { continuation.finish(); return }
for token in model.stream(prompt, cache: kvCache) {
continuation.yield(token)
}
continuation.finish()
}
}
}
}
// Core ML ANE actor — owns the compiled model and prediction context
@globalActor
actor ANEInferenceActor {
static let shared = ANEInferenceActor()
private var compiledModel: MLModel?
func load(url: URL) async throws {
let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine // ANE-first, CPU fallback
compiledModel = try MLModel(contentsOf: url, configuration: config)
}
func predict(input: MLFeatureProvider) async throws -> MLFeatureProvider {
guard let compiledModel else { throw InferenceError.notLoaded }
return try compiledModel.prediction(from: input)
}
// Streaming wrapper used by InferenceCoordinator
func stream(_ request: InferenceRequest) -> AsyncStream<Token> {
AsyncStream { continuation in
Task {
do {
let output = try await predict(input: request.toMLFeatureProvider())
for token in output.toTokenSequence() {
continuation.yield(token)
}
} catch {
// Error surfaces as stream completion; caller inspects request state
}
continuation.finish()
}
}
}
}
Neither actor touches MainActor. Your UI layer subscribes to AsyncStream<Token> and updates SwiftUI state via @MainActor task groups. The inference path never blocks the run loop.
When ANE wins, when GPU wins
| Workload | Backend | First Token (ms) | Tokens/sec | Power Draw |
|---|---|---|---|---|
| Single prompt, 512 ctx | ANE | 38 | 42 | 2.1W |
| Single prompt, 512 ctx | GPU | 61 | 38 | 4.8W |
| Batch=4, 512 ctx | ANE | 44 | 31 | 2.3W |
| Batch=4, 512 ctx | GPU | 63 | 89 | 6.1W |
| 4K context window | ANE | 112 | 28 | 2.6W |
| 4K context window | GPU | 74 | 44 | 5.4W |
Benchmark methodology: Model: Llama 3.2 3B (4-bit AWQ quantized, ~1.8GB on-disk). Device: M3 Pro, 18GB unified memory, macOS 15.2. Measurement: median of 20 inference runs per configuration; first 2 cold-start runs excluded. Run-to-run variance: ±2ms first-token latency, ±1.5 tok/s throughput.
The ANE is the right choice for single-request, short-context workloads — which covers most mobile use cases. The GPU wins decisively once you introduce batching or push past 2K context, because the ANE’s 16-core architecture doesn’t scale the same way across large attention spans.
Rule of thumb: If your p95 context length stays under 1K tokens and you’re serving one request at a time, route to ANE. If you’re building a document summarization pipeline or supporting concurrent requests, MLX on GPU gives you the throughput.
Routing logic at the coordinator layer
actor InferenceCoordinator {
private let aneActor = ANEInferenceActor.shared
private let mlxActor = MLXInferenceActor.shared
func route(request: InferenceRequest) async -> AsyncStream<Token> {
switch request.contextLength {
case ..<1024 where !request.isBatched:
return await aneActor.stream(request)
default:
return await mlxActor.generate(
prompt: request.toMLXArray(),
maxTokens: request.maxTokens
)
}
}
}
This coordinator is the only place routing logic lives. Changing the cutoff threshold becomes a one-line change, not a refactor.
The ANE operator constraint you cannot ignore
Core ML’s ANE scheduler silently falls back to CPU for unsupported operator types. LayerNorm with dynamic shapes is the most common offender in LLM architectures, but it is not the only one. In my experience building production systems on Apple Silicon, these four ops account for the majority of unexpected CPU fallback in transformer pipelines:
- LayerNorm with dynamic sequence length — most frequent culprit; static-shape variants compile cleanly
- RoPE (Rotary Position Embedding) with variable context — dynamic sequence indexing breaks ANE dispatch
- Grouped-query attention (GQA) — multi-head key/value grouping patterns are not fully supported on current ANE hardware
- Custom activation functions — anything beyond ReLU, GELU, and Swish typically falls back to CPU
Use mlmodelc compilation with --compute-units cpuAndNeuralEngine and inspect the compilation report. Any op marked cpu_only in the report is killing your ANE gains. Teams that skip this audit end up getting CPU latency while paying for the ANE marketing story.
Conclusion
One actor per compute backend. Compile the Core ML model and audit the ops report. Let context length and batch size drive routing, not intuition.
The details: MLX GPU and Core ML ANE have incompatible threading models, and mixing them on a shared actor is a deadlock waiting to happen. Swift 6 enforces the boundary at compile time — use it, and make sure every public method on each actor has a defined call site contract, including the streaming variants.
If more than 15% of ops fall back to CPU in the mlmodelc report — particularly LayerNorm, RoPE, or GQA — your model architecture needs modification before ANE routing delivers real gains.
Build a coordinator that makes the routing decision at runtime based on request shape, and benchmark against your actual p95 context distribution before picking a fixed cutoff.
Tags: swift · ios · mobile · architecture