Prefix Cache Architecture
This document describes how AFM stores, restores, guards, and updates prompt-cache state for MLX models. It covers the request path, the radix-tree store, layer-level cache types, the exact-replay safety rules, and the special handling required for recurrent and rotating caches.
Core Idea
AFM caches model-internal layer state, not generated text. The radix tree maps a token prefix to a per-layer cache snapshot.
Fast Path
On a shared prefix, AFM restores layer state, trims it to a safe prefix length, and generates only the remaining suffix.
Safety Guardrails
Exact full-prefix replay is bypassed for recurrent caches, and rotating caches restore metadata differently from simple KV caches.
1. End-to-End Request Flow
LMInput, flatten token IDs, decide whether prefix cache is allowed.
effectiveCachedPrefix. Apply safety bypasses for exact full replay on recurrent caches.
state and metaState, then trim restored layers down to the allowed prefix.
2A. Three Architectural Levels, Four Cache Families
1. The Radix Cache
RadixTreeCache is the outer lookup structure. It is not the model's attention
cache itself. Its job is only to answer: “Have we seen this token prefix before?”
2. The Live Request Cache
Every request gets a fresh runtime cache stack via
context.model.newCache(parameters: ...). On a hit, AFM restores data into this
live cache and then continues generation from there.
3. The Per-Layer Cache Classes
Inside the live cache stack, each layer may use a different implementation such as
KVCacheSimple, RotatingKVCache, or ArraysCache.
These are not separate top-level systems; they are layer-specific cache shapes.
Short Version
There is one prefix-cache system. It has three architectural levels: the radix index, the live request cache stack, and the per-layer cache objects. Inside that third level, there are four main cache families.
Why the Distinction Matters
Bugs can happen at different levels: radix lookup bugs, restore bugs in the live stack, or cache-family-specific restore bugs such as the rotating-cache issue fixed for GPT-OSS.
1A. Quick Reference Table
Architectural Levels
| Level | What It Does | Examples |
|---|---|---|
| 1. Prefix Index | Looks up shared prompt-token prefixes. | RadixTreeCache |
| 2. Live Request Cache | Fresh runtime cache stack for the current generation. | generationCache, batch prefill cache |
| 3. Per-Layer Cache Objects | Hold the actual model state for each cached layer. | KVCacheSimple, RotatingKVCache, MambaCache, CacheList |
Cache Families
| Family | Used For | Tensor Layout / Indexing |
|---|---|---|
KVCacheSimple |
Standard attention KV state. | [batch, kv_heads, seq_len, head_dim]; sequence grows on axis 2. |
RotatingKVCache |
Sliding-window / ring-buffer attention. | [batch, kv_heads, cache_slots, head_dim]; logical time is tracked by offset and ring cursor idx on axis 2. |
ArraysCache / MambaCache |
Recurrent or state-space layers. | No single universal 4D layout. Stores an array-list of tensors; shape is model-specific. MambaCache is a 2-slot ArraysCache. |
CacheList |
Composite wrapper for multiple sub-caches in one logical layer. | No single tensor layout. Flattens and delegates indexing/shape semantics to child caches. |
2. Main Components
Request-Side Services
MLXModelService: direct non-batched generation path.BatchScheduler: batched prefill and generation path.- Both own the runtime logic for cache lookup, restore, trim, suffix generation, and cache reinsertion.
Shared Prefix Store
RadixTreeCache: token-prefix tree.RadixNode: edge-labeled node with optional cache entry.KVCacheEntry: storestokens,layerStates, andlayerMetaStates.
Per-Layer Cache Types
KVCacheSimple RotatingKVCache ArraysCache MambaCache CacheListObserved Model Mappings
- Qwen3.5 A3B: recurrent layers via
MambaCache/ArraysCache. - Nemotron Nano: mixed recurrent + regular KV layers.
- GPT-OSS 20B: sliding-window attention via
RotatingKVCache.
3. What the Radix Tree Actually Stores
Radix Key
[token_0, token_1, token_2, ..., token_n]
Each path in the tree is a token prefix. Matching is performed against prepared prompt tokens, not raw user text.
Radix Payload
KVCacheEntry { tokens, layerStates, layerMetaStates }
layerStates is the tensor payload from each layer cache.
layerMetaStates is the structural metadata required by cache classes such as
RotatingKVCache.
cache[i].state.
cache[i].metaState.
4. Restore Decision Logic
Step A: Find a Prefix
findPrefix(tokens)returns the longest cached prefix.- The result includes the matched length and the cached layer payloads.
Step B: Compute a Safe Prefix
effectiveCachedPrefix = min(prefixLen, inputCount - 16)- This leaves a suffix so the model reprocesses the prompt boundary.
- For recurrent caches on an exact full replay, AFM bypasses restore entirely.
ArraysCache / CacheList present?
5. Restore Mechanics by Cache Type
Family 1: KVCacheSimple
- Restore tensor state directly.
- Trim excess tokens.
- Physical truncation is safe.
Family 2: Recurrent Array Caches
ArraysCache,MambaCache,CacheList.- Partial restore is allowed.
- Exact full replay is bypassed for correctness.
Family 3: RotatingKVCache
- Needs both tensor state and structural metadata.
- Restore
keep,maxSize,step. - Reset live
offset/idxto restored state length. - Skip physical truncation round-trip.
Family 4: CacheList
CacheList is a composite wrapper around multiple sub-caches for a single logical layer.
It is its own cache family structurally, even though it may contain recurrent caches or standard KV caches inside.
If you want the simplest counting scheme, use this one: 3 architectural levels, 4 main cache families.
Dimension and Indexing Map
| Family | Dim 0 | Dim 1 | Dim 2 | Dim 3 | Indexing System |
|---|---|---|---|---|---|
KVCacheSimple |
batch | KV heads | sequence length | head dimension | Linear append-only indexing on axis 2; offset is current logical length. |
RotatingKVCache |
batch | KV heads | cache slots / time ring | head dimension | Ring-buffer indexing on axis 2; offset is logical history length, idx is current write cursor, keep reserves sink tokens. |
ArraysCache |
model-specific | model-specific | model-specific | model-specific | List of tensors, not one canonical 4D KV layout. Interpretation belongs to the model layer implementation. |
MambaCache |
slot 0 tensor: model-specific | slot 1 tensor: model-specific | not fixed | not fixed | Two-tensor ArraysCache; used for state-space / Mamba-style layers. |
CacheList |
child 0 | child 1 | child-defined | child-defined | Composite index space; delegates actual dimensional meaning to sub-caches and flattens their state for storage. |
5A. What Decides Which Cache Type Is Used?
Primary Decider: The Model Architecture
AFM does not arbitrarily pick cache classes at runtime. The model implementation in
MLX defines what each layer needs when it builds caches inside
newCache(...) or the model's cache factory.
- Plain transformer attention layers usually use
KVCacheSimple. - Sliding-window attention layers use
RotatingKVCache. - Recurrent or state-space layers use
ArraysCache/MambaCache. - Composite layers may wrap multiple sub-caches in
CacheList.
Secondary Decider: Model Parameters
Some models also change cache shape based on configuration such as sliding-window size or whether a layer is an attention layer versus a recurrent/state-space layer.
In practice, the decisive code lives in the vendor model definitions and cache helpers,
especially AFMKit's vendor/MLX/mlx-swift-lm/Libraries/MLXLMCommon/KVCache.swift and the
specific model file's makeCache / newCache logic.
KVCacheSimple, RotatingKVCache, ArraysCache, CacheList.
Example: GPT-OSS 20B
Sliding-window layers choose RotatingKVCache, which is why metadata restore matters there.
Example: Qwen3.5 A3B
Recurrent/state-space layers use array-based caches, which is why exact full replay had to be bypassed.
Example: Standard Attention
Normal transformer attention typically uses KVCacheSimple, the least complicated restore case.
Model-to-Cache Mapping as Coded
| Model File | Decision Logic | Cache Mapping |
|---|---|---|
GPTOSS.swift |
Iterates model.layerTypes |
full_attention → standard KV cacheanything else → RotatingKVCache(maxSize: slidingWindow, keep: 0)
|
Qwen3Next.swift |
Checks layer.isLinear |
linear/state-space layer → MambaCache()attention layer → KVCacheSimple()
|
NemotronH.swift |
Uses hybridOverridePattern block type |
.mamba → MambaCache().attention → KVCacheSimple().mlp / .moe → no cache
|
BaichuanM1.swift, FalconH1.swift, GLM5MoeDsa.swift |
Composite layer implementations |
Use CacheList(...) to bundle multiple sub-caches for one layer.
|
Full Portfolio Cache-Class Map
| Model | Cache Rule | Family / Output |
|---|---|---|
GPTOSS.swift |
model.layerTypes: full_attention vs other |
StandardKVCache or RotatingKVCache |
Olmo3.swift |
args.layerTypes: full_attention vs other |
KVCacheSimple or RotatingKVCache |
Mistral3Text.swift |
layer.useSliding |
KVCacheSimple or RotatingKVCache |
Gemma3Text.swift |
Global layer by slidingWindowPattern vs sliding layer |
StandardKVCache or RotatingKVCache |
Gemma3nText.swift |
layerTypes: full_attention vs sliding_attention |
StandardKVCache or RotatingKVCache |
Exaone4.swift |
layer.attention.isLocal |
StandardKVCache or RotatingKVCache |
AfMoE.swift |
layerUsesSliding |
KVCacheSimple or RotatingKVCache |
Qwen3Next.swift |
layer.isLinear |
MambaCache or KVCacheSimple |
Qwen3_5MoE.swift |
fullAttentionInterval: linear vs full-attention layer |
MambaCache or KVCacheSimple |
Qwen3_5MoEVL.swift |
Same text backbone rule as Qwen3_5MoE |
MambaCache or KVCacheSimple |
Jamba.swift |
layer.isAttn |
KVCacheSimple or MambaCache |
GraniteMoeHybrid.swift |
configuration.layerTypes == "mamba" |
MambaCache or KVCacheSimple |
LFM2.swift |
fullAttnIdxs.contains(layerIdx) |
KVCacheSimple or MambaCache |
LFM2MoE.swift |
fullAttnIdxs.contains(layerIdx) |
KVCacheSimple or MambaCache |
LFM2VL.swift |
Text backbone uses fullAttnIdxs.contains(layerIdx) |
KVCacheSimple or MambaCache |
NemotronH.swift |
hybridOverridePattern block type |
MambaCache, KVCacheSimple, or no cache for .mlp/.moe |
FalconH1.swift |
Fixed composite per layer | CacheList(MambaCache(), KVCacheSimple()) |
BaichuanM1.swift |
Fixed conv cache plus sliding-or-full attention cache | CacheList(MambaCache(), RotatingKVCache or KVCacheSimple) |
GLM5MoeDsa.swift |
Fixed composite per layer | CacheList(KVCacheSimple(), KVCacheSimple()) |
Gemma3.swift (VLM) |
Delegates to text model with global-vs-sliding rule | StandardKVCache or RotatingKVCache |
Mistral3.swift (VLM) |
Delegates to Mistral3Text |
KVCacheSimple or RotatingKVCache |
Pixtral.swift (VLM) |
Delegates to language model cache factory | Depends on wrapped text model |
Gemma3nText.swift wrapper Gemma3.swift wrapper paths |
Wrapper newCache just forwards to underlying language model |
Same cache family mapping as delegated text backbone |
6. Known Failure Modes and Why the Current Design Exists
Qwen Exact-Replay Divergence
Recurrent cache layers could produce shortened completions when the cache restore covered
the entire prompt. AFM now treats that as unsafe and logs
outcome=exact-replay-bypass.
GPT-OSS Rotating-Cache Crash
A rotating cache hit needed metadata restoration, but the original radix store only saved tensor state. A naive metadata restore was also unsafe because ring-buffer cursor values must stay coherent with restored state length. AFM now stores metadata, restores it carefully, and skips unsafe physical truncation for rotating caches.
7. Logging and Observability
PrefixCache Logs
outcome=missoutcome=hitoutcome=exact-replay-bypass- Includes input tokens, cached tokens, suffix tokens, radix entry count, and timing breakdowns.
ChunkStats Logs
- Preliminary stream usage chunk.
- Final usage/timing chunk.
- Used to verify cache-hit accounting in streaming mode.
8. File Map
Repo Files
Sources/MacLocalAPI/Models/MLXModelService.swiftSources/MacLocalAPI/Models/BatchScheduler.swiftSources/MacLocalAPI/Models/RadixTreeCache.swift
Vendor Cache Types
- AFMKit
vendor/MLX/mlx-swift-lm/Libraries/MLXLMCommon/KVCache.swift