DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Wiring CoreML ANE Execution to Swift Concurrency: Scheduling On-Device Inference Without Blocking the Main Actor

---
title: "CoreML ANE + Swift Concurrency: Block-Free On-Device Inference"
published: true
description: "Learn how to wire CoreML Neural Engine execution to Swift 6 structured concurrency without blocking the cooperative thread pool, covering MLComputeUnits, continuations, and TaskGroup batching."
tags: ios, swift, mobile, architecture
canonical_url: https://mvpfactory.co/blog/coreml-ane-swift-concurrency
---
Enter fullscreen mode Exit fullscreen mode

What We Are Building

By the end of this tutorial you will know how to dispatch CoreML predictions to the Apple Neural Engine without stalling Swift 6's cooperative thread pool. We will cover withCheckedContinuation bridging, explicit MLComputeUnits selection, TaskGroup batching for parallel inference, and how to verify everything is working correctly in Instruments.

If you have ever slapped async in front of MLModel.prediction() and called it a day — this is for you.

Prerequisites

  • Xcode 15+, Swift 6
  • A CoreML model (.mlpackage or .mlmodelc)
  • Basic familiarity with async/await and DispatchQueue

Step 1 — Understand Why Naive Wrapping Fails

Here is the mistake I see in almost every production codebase that ships on-device inference:

func runInference(input: MLFeatureProvider) async throws -> MLFeatureProvider {
    return try model.prediction(from: input) // ❌ Blocks a cooperative thread
}
Enter fullscreen mode Exit fullscreen mode

This looks async. It is not safe. Swift 6's cooperative thread pool is bounded — one thread per CPU core. Handing a synchronous, long-running call to it causes priority inversions and UI stalls under load.

The math on an iPhone 15 Pro: a single CoreML prediction on ANE takes ~2–4ms, but synchronous blocking on the cooperative pool adds 8–20ms of scheduling overhead under thread pressure. That compounds fast in health sensor batching scenarios.


Step 2 — Bridge Correctly with withCheckedContinuation

Here is the pattern I use in every project that does on-device inference:

private let inferenceQueue = DispatchQueue(
    label: "com.app.ane-inference",
    qos: .userInitiated
)

func runInference(input: MLFeatureProvider) async throws -> MLFeatureProvider {
    try await withCheckedThrowingContinuation { continuation in
        inferenceQueue.async {
            do {
                let result = try self.model.prediction(from: input)
                continuation.resume(returning: result)
            } catch {
                continuation.resume(throwing: error)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The continuation parks the Swift async context immediately. The cooperative thread is freed. The dedicated queue handles the synchronous CoreML work and resumes the continuation when the ANE returns results. The explicit qos: .userInitiated gives the scheduler what it needs to avoid priority inversion.


Step 3 — Force the ANE Path with MLComputeUnits

The docs do not make this obvious enough: CoreML's scheduler makes heuristic decisions by default. You need to be explicit.

MLComputeUnits Hardware Target Use Case
.all ANE → GPU → CPU fallback Default; unpredictable latency
.cpuAndNeuralEngine ANE + CPU only Predictable ANE path, no GPU jitter
.cpuAndGPU GPU + CPU only Models that underperform on ANE
.cpuOnly CPU only Debugging and regression testing

For health inference workloads — HRV models, step cadence classifiers — .cpuAndNeuralEngine is almost always the right call:

let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine
let model = try MLModel(contentsOf: modelURL, configuration: config)
Enter fullscreen mode Exit fullscreen mode

Step 4 — Batch Parallel Inference with TaskGroup

When running multiple independent predictions simultaneously, TaskGroup composes cleanly with the continuation bridge:

func runParallelInference(inputs: [MLFeatureProvider]) async throws -> [MLFeatureProvider] {
    try await withThrowingTaskGroup(of: (Int, MLFeatureProvider).self) { group in
        for (index, input) in inputs.enumerated() {
            group.addTask { (index, try await self.runInference(input: input)) }
        }
        var results = Array<MLFeatureProvider?>(repeating: nil, count: inputs.count)
        for try await (index, result) in group {
            results[index] = result
        }
        return results.compactMap { $0 }
    }
}
Enter fullscreen mode Exit fullscreen mode

Each task parks via the continuation bridge. The ANE scheduler sees concurrent work and pipelines predictions. In production health apps, this pattern cuts wall-clock batch time by 35–50% compared to sequential execution.


Gotchas

Here is what will save you hours of debugging.

Gaps in the Neural Engine lane mean thread stalls before work reaches the ANE queue — your continuation bridging has a hole. Check QoS settings on the dispatch queue.

GPU fallback markers in Instruments mean your MLComputeUnits selection is not being respected. This almost always means the model has unsupported ops. Custom layers and some recurrent architectures do not run on ANE. Inspect the operator graph.

Thermal throttle events mean ANE frequency is scaling down under sustained load. Batch size matters here — profile under realistic workload, not a single prediction.

Do not share a serial queue across models. Each model should have its own inferenceQueue. Sharing serializes what should be parallel ANE work.


Profiling: Verify in Instruments

Open Instruments → Core ML template. The Neural Engine lane shows ANE utilization over time. A healthy trace shows dense, contiguous ANE utilization with minimal gaps. If your TaskGroup batching is working, you will see concurrent prediction spans, not sequential ones.

Profile before shipping. The trace will show you what the code cannot.


Conclusion

Never pass MLModel.prediction() directly to an async function. Bridge it through withCheckedContinuation onto a dedicated DispatchQueue with explicit QoS — there is no shortcut in Swift 6.

Set MLComputeUnits explicitly. Default .all introduces latency variance that compounds under load. For production inference, .cpuAndNeuralEngine locks in the ANE path and removes GPU scheduling noise.

Then profile with Instruments' Neural Engine lane to verify work actually reaches the ANE and that TaskGroup batching produces the concurrent utilization you expect. It often does not, and the trace will tell you exactly why.

Useful references:

Top comments (1)

Collapse
 
sam_tech_e3c30d03221da839 profile image
Sam Tech

Hello, I'm in need a pro developer for my project.