During conversion, mark KV buffers as stateful
TL;DR
Core ML stateful models, introduced in iOS 18, let you persist key-value cache tensors across inference calls, eliminating the expensive prefill recalculation on every chat turn. Combined with correct ANE execution hints and Swift 6 actor isolation, you can build an on-device chat experience that doesn’t collapse under the quadratic cost that kills most naive implementations.
The problem: every turn re-prefills the entire context
Most teams get this wrong. They feed the entire conversation history into the model on every turn and absorb the full prefill cost each time. In transformer attention, prefill is O(n²) in sequence length — doubling your context quadruples the compute. On an iPhone, that translates directly to latency and thermal throttling after just a few exchanges.
The fix is textbook in server-side inference: cache the key-value pairs from prior tokens and only compute attention for the new ones. Core ML stateful models give you this primitive natively on-device, backed by the ANE.
Declaring stateful tensors in your .mlpackage
Stateful models in Core ML extend the standard model spec with MLState — a persistent buffer that survives across prediction calls on the same MLModel instance.
In coremltools, you declare the KV-cache buffers as a list passed to the states parameter:
import coremltools as ct
# During conversion, mark KV buffers as stateful
kv_cache_state = ct.StateType(
wrapped_type=ct.TensorType(
shape=(num_layers, num_heads, max_seq_len, head_dim)
),
name="kv_cache"
)
mlmodel = ct.convert(traced_model, states=[kv_cache_state], ...)
mlmodel.save("chat_model.mlpackage")
At inference time, you create a single MLState object and pass it to every prediction:
let state = try model.makeState()
func generateNextToken(inputIds: MLMultiArray, cachePosition: Int) throws -> MLMultiArray {
let input = ChatModelInput(input_ids: inputIds, cache_position: cachePosition)
let output = try model.prediction(from: input, using: state)
return output.logits
}
The cache tensors are read and written in-place inside the model graph. No copy, no re-allocation per turn.
Wiring ANE execution hints to avoid GPU fallback
The stateful read/write path introduces scatter-gather memory access patterns that the GPU handles poorly on Apple Silicon. Left unconfigured, Core ML will fall back to GPU for the state update operations, negating much of the latency benefit.
Force ANE execution explicitly:
let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine // Exclude GPU explicitly
let model = try MLModel(contentsOf: modelURL, configuration: config)
| Compute Units | First-token latency | Per-token latency | Thermal impact |
|---|---|---|---|
.all (default) | ~210ms | ~38ms | High (GPU contention) |
.cpuAndNeuralEngine | ~180ms | ~22ms | Low |
.cpuOnly | ~640ms | ~95ms | Minimal |
Measured on iOS 18 / A17 Pro at sustained load across a 512-token conversation.
Locking to ANE yields roughly 40% better per-token latency compared to the default policy. The device also runs noticeably cooler across a sustained conversation, which matters more than it sounds — thermal throttling will eat those gains back if you ignore it.
Cache invalidation across conversation resets
When the user starts a new conversation, the stale KV tensors in your MLState must be zeroed out. MLState does not reset automatically. It persists until the object is deallocated or you explicitly clear it.
Just discard and recreate the state:
actor InferenceSession {
private var state: MLState
private let model: MLModel
init(model: MLModel) throws {
self.model = model
self.state = try model.makeState()
}
func resetConversation() throws {
state = try model.makeState() // Fresh zero-filled buffers
}
}
Avoid trying to zero-fill the state tensors manually. The internal layout of MLState buffers is opaque and may change across OS versions.
Handling max_seq_len overflow
Every stateful model has a fixed max_seq_len dimension baked in at conversion time. When cache_position reaches that limit, writing beyond the buffer boundary produces undefined behavior — silently corrupt output in the best case, a model crash in the worst.
In production, two options:
- Sliding window truncation: Shift the cache contents and continue generating. This requires a custom Core ML op or a CPU-side copy before each overflow turn. Complex and fragile to maintain across coremltools versions.
- Full reset with context summarization (recommended): When
cache_positionapproachesmax_seq_len - safety_margin, summarize the conversation to date using a lightweight prompt, reset theMLState, and prefill the summary as the new seed context. The user sees no interruption. The model sees a clean cache.
func generateNextToken(inputIds: MLMultiArray, cachePosition: Int) throws -> MLMultiArray {
if cachePosition >= maxSeqLen - safetyMargin {
try await summarizeAndReset()
}
// ... normal inference
}
The summarize-and-reset pattern is what I’d recommend for any production chat application. It bounds memory usage, avoids fragile buffer manipulation, and degrades gracefully rather than silently.
The Swift 6 async boundary: preventing state corruption
This is where most implementations introduce a subtle race. If two inference requests overlap — say, a user taps send while the previous token stream is still running — both writes corrupt the shared KV-cache state.
Swift 6 strict concurrency enforcement makes this error visible at compile time when you wrap the inference session in an actor. The actor serializes all calls to generateNextToken and resetConversation automatically. Do not use nonisolated to work around the compiler warning. That warning is protecting you from a real data race.
I’ve seen this bite teams in ways that are genuinely hard to debug. One concurrent write to a cache tensor produces wrong output that looks plausible enough to pass casual testing. You won’t catch it until a user reports that the model started giving nonsensical responses mid-conversation. The actor boundary isn’t ceremony — it’s load-bearing.
What actually matters
-
Declare KV-cache buffers as a
ct.StateTypelist at conversion time, passed viastates=[...], not as a dict. Nothing else works without this. -
Set
computeUnits = .cpuAndNeuralEngineexplicitly. The default.allpolicy routes stateful scatter-gather ops to the GPU, adding latency and heat. -
Wrap your
MLModelandMLStatepair in a Swift 6actor, and guard againstmax_seq_lenoverflow. Actor isolation prevents concurrent state corruption. The summarize-and-reset strategy at the sequence boundary is the only production-safe way to handle long conversations.