Add checkpoint-based prefix caching for hybrid/recurrent models#270
Add checkpoint-based prefix caching for hybrid/recurrent models#270d0lphin wants to merge 1 commit into
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a8602c8a98
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # For purely recurrent models (no offset attribute), try checkpoint restore | ||
| if self._checkpoint_store is not None: | ||
| result = self._checkpoint_store.find_longest_prefix(prompt_tokens) | ||
| if result is not None: | ||
| prefix_len, restored_cache = result | ||
| self.cache = restored_cache | ||
| if self.draft_model is not None: | ||
| self.cache += make_prompt_cache(self.draft_model) | ||
| self.tokens = prompt_tokens | ||
| return prompt_tokens[prefix_len:] |
There was a problem hiding this comment.
Preserve at least one token on checkpoint restore
When restoring from a checkpoint, the code returns prompt_tokens[prefix_len:] without applying the num_tokens_to_exclude adjustment that _find_common_prefix enforces. If the checkpoint matches the entire prompt (or leaves fewer than num_tokens_to_exclude tokens), update_cache later sees an empty prompt_tokens, sets num_tokens_to_exclude to 0, and returns an empty array. Downstream generation in the text-only path expects at least one token (the code normally keeps the last token out of the cache), so exact-prefix hits can yield zero-length prompts and break generation. Consider reducing prefix_len so that at least num_tokens_to_exclude tokens remain outside the cache before returning.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — this is a valid edge case. It triggers when a new prompt exactly matches a checkpoint key in length (e.g. request 1 is 100 tokens → checkpoint saved at key tokens[:99], then request 2 is exactly 99 tokens matching that prefix). The checkpoint restore returns zero remaining tokens and generation breaks.
Fixed in 123296a: both checkpoint restore paths in _get_unprocessed_tokens now truncate the query to find_longest_prefix by num_tokens_to_exclude, mirroring the same guard that _find_common_prefix already applies (line 108–113). Since recurrent state can't be trimmed, too-long checkpoints must be excluded from the search entirely.
max_prefix = len(prompt_tokens) - num_tokens_to_exclude
result = self._checkpoint_store.find_longest_prefix(prompt_tokens[:max_prefix])Note: the batched path (_fetch_cache_with_checkpoint_fallback) was already safe — it always returns prompt_tokens[-1:].
|
I have read the CLA Document and I hereby sign the CLA |
|
recheck |
a8602c8 to
123296a
Compare
|
Codex comment addressed. Tested in LM Studio as well, prefix cache works, however we are serializing now everything which messes up concurrency completely. Fix incoming. |
Hybrid models like Qwen3-Coder-Next mix KVCache (trimmable attention) with ArraysCache (non-trimmable recurrent/DeltaNet layers), causing can_trim_prompt_cache() to return False. This completely disables prefix caching — every request reprefills the entire context from scratch, making multi-turn and agentic use cases impractically slow. This adds RecurrentCheckpointStore, an LRU-bounded store that saves deep-copied cache snapshots at 512-token intervals during prefill. When a subsequent request shares a prefix, the nearest checkpoint is restored and only the remaining tokens need processing. Key design decisions: - Zero overhead for standard transformers: the checkpoint store is only created when can_trim_prompt_cache() returns False - Intermediate checkpoints every 512 tokens enable partial prefix reuse when suffixes diverge (e.g. same system prompt, different user message) - Deep copies with mx.eval ensure checkpoint independence - Checkpoints are cleared on draft model changes to prevent desync - Both sequential (CacheWrapper) and batched (BatchedModelKit) code paths are covered Measured on Qwen3-Coder-Next-4bit with a ~950-token shared prefix: - Cold TTFT: 1.19s - Warm prefix (checkpoint restore at 512): 0.39s (3x faster) - Exact match (full checkpoint restore): 0.09s (13x faster) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123296a to
fc734d2
Compare
Summary
RecurrentCheckpointStore, an LRU-bounded store that saves deep-copied cache snapshots at 512-token intervals during prefillCacheWrapper(sequential) andBatchedModelKit(batched) code pathscan_trim_prompt_cache()returnsFalseProblem
Hybrid models like Qwen3-Coder-Next mix
KVCache(trimmable attention) withArraysCache(non-trimmable recurrent/DeltaNet layers). Sincecan_trim_prompt_cache()requires all layers to be trimmable, it returnsFalsefor the entire model — completely disabling prefix caching.This means every request with a diverging suffix (the most common real-world pattern: same system prompt, different user message) reprefills the entire context from scratch. On a ~13.5k token context, this produces ~5.9s warm TTFT instead of the expected ~200ms.
The
LRUPromptCachein batched mode handles exact matches and prefix extensions correctly, but the "diverging suffix" path requirescan_trim_prompt_cache()to returnTrue— which it never does for hybrid models.Why you can't just trim recurrent state
For KV cache (attention layers): trimming to position N means "keep K/V tensors up to N, discard the rest."
For
ArraysCache(recurrent/DeltaNet layers): the hidden state at position N encodes the entire sequence up to N. You can't "un-process" tokens — you need an explicit checkpoint saved at position N.Solution
Bypass
trim_prompt_cache()entirely by implementing checkpoint-based prefix reuse at the mlx-engine level:copy.deepcopy()+mx.eval()snapshot of the cache after each chunkThis approach requires no changes to mlx-lm — the checkpoint store sits alongside the existing caching infrastructure.
Inspiration
This is directly inspired by llama.cpp's approach to the same problem:
llama.cpp saves recurrent state snapshots at strategic positions and restores them when a new request shares a prefix, instead of reprocessing from scratch.
Benchmark Results
Measured on Qwen3-Coder-Next-4bit (Apple Silicon), ~950-token shared system prompt:
Changes
mlx_engine/recurrent_checkpoint_store.pysave(),find_longest_prefix(),clear()mlx_engine/cache_wrapper.pymlx_engine/model_kit/batched_model_kit.py_prefill_with_checkpoints(),_fetch_cache_with_checkpoint_fallback()tests/test_recurrent_checkpoint_store.pytests/test_cache_wrapper.pyTest plan
test_recurrent_checkpoint_store.py+TestCacheWrapperCheckpointing)batched_demo.py --parallel 2: confirmed checkpoint restore across concurrent requestsNone, zero overhead)🤖 Generated with Claude Code