--- title: "Sub-30ms On-Device Semantic Search with Swift 6 Actor Isolation and Core ML" published: true description: "Model your Core ML prediction queue as a custom actor, isolate embedding buffers, and use async let to hit sub-30ms p95 semantic search latency on A17 Pro — with zero data races at compile time." tags: swift, ios, mobile, architecture canonical_url: https://mvpfactory.co/blog/swift-6-coreml-actor-semantic-search --- ## What You Will Build By the end of this tutorial you will have a working on-device semantic search pipeline in Swift 6 that hits sub-30ms p95 query latency on an A17 Pro. We will model the Core ML prediction queue as a custom actor, keep embedding buffers actor-isolated, and structure the async coordinator so ANE inference never serializes with your UI update cycle. Swift 6's strict concurrency model is a feature here, not a burden. Let me show you a pattern I use in every project.  ---  ## Prerequisites  - Xcode 15.3+ with Swift 6 concurrency enabled - An iOS 17+ target device (A-series chip, ideally A17 Pro for the benchmarks to match) - A Core ML embedding model — we use MiniLM-L6-v2 4-bit palettized via `coremltools 7.2` - Basic familiarity with `async/await` and Swift actors  ---  ## Step 1 — Model the Inference Boundary as an Actor The concrete cost of getting this wrong: p95 query latency climbs past 60ms and frame drops appear under active search. The root cause is almost always the same — treating Core ML prediction as just another async call. The Apple Neural Engine has its own scheduling queue. Invoking it from a `@MainActor`-bound view model serializes ANE work with your UI cycle. Swift 6 makes that mistake a compile error. Here is the minimal setup to get this working: 


swift
actor EmbeddingEngine {
private let model: MLModel

init(modelURL: URL) throws { let config = MLModelConfiguration() config.computeUnits = .cpuAndNeuralEngine self.model = try MLModel(contentsOf: modelURL, configuration: config) } func embed(_ text: String) async throws -> [Float] { let input = try EmbeddingInput(text: text) let output = try model.prediction(input: input) return output.embeddingVector } 

}

 The key detail is `computeUnits: .cpuAndNeuralEngine`. Core ML manages the CPU/ANE split internally — your actor just stays out of its way. --- ## Step 2 — Wire the Search Coordinator The coordinator lives at `@MainActor` but delegates inference immediately using `async let`. This keeps `@MainActor` free to process UI events while Core ML runs on its own scheduler: 


swift
struct EmbeddedDocument {
let id: String
let vector: [Float]
}

@MainActor
final class SearchCoordinator: ObservableObject {
private let engine: EmbeddingEngine
private let index: VectorIndex

func search(query: String) async throws { async let queryEmbedding = engine.embed(query) async let candidates = index.topK(k: 20) let (qVec, docs) = try await (queryEmbedding, candidates) results = docs .map { RankedResult(id: $0.id, score: cosineSimilarity(qVec, $0.vector)) } .sorted { $0.score > $1.score } } 

}

 --- ## Step 3 — Choose the Right Dispatch Strategy Here is the gotcha that will save you hours: most teams reach for `TaskGroup` assuming it parallelizes ANE work. It does not — the ANE is a serial resource. Here are the actual numbers on an A17 Pro, 200 runs averaged over a 500-document corpus: | Strategy | p50 | p95 | Notes | |---|---|---|---| | Sequential await | 42ms | 61ms | Baseline | | async let (2 concurrent) | 18ms | 27ms | Sweet spot | | TaskGroup (8 children) | 22ms | 34ms | Child task overhead hurts | | TaskGroup (2 children) | 19ms | 28ms | Near parity with async let | `async let` wins because it frees `@MainActor` cheaply — it is not controlling ANE pipelining, that is Core ML internals. Reserve `TaskGroup` for genuinely heterogeneous work like running BM25 scoring concurrently alongside embedding. --- ## Step 4 — Ship a Quantized Model A 4-bit palettized model via `ct.optimize.coreml.palettize_weights` runs at roughly 3–4ms per inference on an A17 Pro. FP32 is approximately 14ms. The accuracy delta on English semantic similarity tasks is under 2% Spearman correlation. That tradeoff is not a close call for real-time UX. --- ## Gotchas **Cold model load.** First inference after `MLModel(contentsOf:)` spikes 200–400ms while the model is compiled and staged to the ANE. Warm your model at app launch with a background `Task` in your app initializer — never on the first user query. The docs do not emphasize this enough. **`MLMultiArray` memory pressure.** Holding multiple instances across concurrent embedding requests escalates memory fast. The actor boundary serializes these naturally for a single-model setup. If you pool multiple `EmbeddingEngine` instances for throughput, track live buffer count explicitly and back-pressure the queue before you hit memory warnings. **TaskGroup over-reach.** Do not use it to "parallelize" repeated calls to a single serial compute resource. You will add child task overhead and gain nothing. Use it when the work is genuinely different in kind. *(Side note: if you are spending long sessions at the desk profiling Core ML pipelines, HealthyDesk is worth having running in the background — it fires break and desk-exercise reminders so you surface for air occasionally. Small thing that compounds.)* --- ## Conclusion Three things to carry into your next project: 1. **Model Core ML as an actor boundary from day one.** Swift 6's concurrency checker enforces this statically. Isolate `MLModel` and all `MLMultiArray` buffers inside a custom actor before you write your first prediction call. 2. **Prefer `async let` over `TaskGroup` for ANE-bound workloads.** The gain is freeing `@MainActor`, not parallelizing a serial resource. 3. **Ship quantized models and warm them at launch.** 4-bit quantization delivers a 3–4x inference speedup with under 2% accuracy loss for embedding tasks. Get the actor boundary right once, and Swift 6 keeps it right permanently. **Resources:** - [Core ML documentation — MLModelConfiguration](https://developer.apple.com/documentation/coreml/mlmodelconfiguration) - [coremltools palettization API](https://apple.github.io/coremltools/docs-guides/source/opt-palettization-overview.html) - [Swift concurrency — actors](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/)