[Feature]: KV-cache reuse for shared-prefix one-shot requests (prefix caching / partial truncate)
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 1.9k
- Forks
- 152
- Avg merge
- 4h 14m
- Merged PRs (30d)
- 11
Description
Suggestion Description
FLM's KV reuse only serves conversations that grow. A very common serving pattern gets zero reuse today: many independent one-shot requests sharing a long stable prefix (instructions, reference documents, few-shot rubrics, RAG context) and differing only in a small trailing payload. Every such request re-prefills the entire shared prefix. On NPU hardware this dominates latency and power: in our workload (~1,900-token prompts sharing ~1,880 tokens, ~35-token payloads), every request pays ~7 s of NPU prefill for ~35 tokens of new content.
The blocker is architectural, and it sits in the engine's cache API. We traced the full request path and are asking for two small primitives that would unlock this class of workloads — and make FLM's caching equivalent to what llama-server has shipped for years.
The workload
Typical shapes: batch raters/classifiers, evaluation harnesses scoring many samples against one long rubric, RAG pipelines with a fixed retrieved corpus, templated extraction over fixed documents.
Client-side workarounds exist — e.g. the client maintains a growing fake conversation so the round-based cache matches — but they:
- multiply upload volume O(N²),
- make the model see prior requests' payloads (contaminating outputs),
- push complexity into every client.
Why the current architecture can't serve it
We traced the full request path; these are the specific blockers:
-
PromptCache::can_use_cache()is conversation-shaped, not prefix-shaped. It requiresmessages.size() > 2and that all previously cached messages reappear as a strict prefix of the new message list. One-shot requests fail the first gate immediately, no matter how much text they share with previous requests. -
_shared_insert()is append-only; divergence clears everything. The token-level prefix walk againsttoken_historyskips matched leading tokens, but any mid-stream divergence callsclear_context()— the whole KV cache is dropped and re-prefilled. Two requests sharing their first 90% and diverging in the last 10% reuse nothing. -
The engine exposes no partial-truncate primitive. The
causal_lminterface offersprefill,forward,clear_context(), and a single-slotcheckpoint()/restore(). There is notruncate(len)/seq_rm(pos)equivalent, andget_k_cache()/get_v_cache()are read-only — so even a wrapper-level implementation is impossible against the prebuilt engine libraries (libqwen3_npu.soetc.). -
Checkpoint placement is the deeper bottleneck. A checkpoint can only be captured at the current end of cache. Prefix reuse would be nearly free if a checkpoint — or a raw rewind — could be placed inside an already-prefilled prompt (i.e. at the prefix/payload boundary). Today the entire prefix must be re-prefilled just to place a cache state at that boundary.
How llama.cpp solves the same problem (existence proof)
llama.cpp's KV cache is cell-addressable with per-cell sequence tags and a logical n_past cursor. Its core primitives — llama_kv_cache_seq_rm(seq, pos0, pos1) (remove a token range), plus seq_keep/seq_cp/seq_add (keep/copy/branch sequences) — mean "rewind to position N" is simply dropping cells ≥ N; attention only reads below the cursor, so it's O(range) bookkeeping, not recompute.
On top of that, llama-server keeps a multi-entry prompt cache: it stores the tokenized prompts of the last N conversations, and per request computes the longest common token prefix (LCP) against a cached entry, truncates the cached KV to the LCP via seq_rm, and prefills only the remainder. Consequences worth highlighting:
- Mid-prompt divergence is fine: two requests sharing 90% and diverging at token 1,700 still reuse 1,700 tokens. There is no "clear on mismatch" cliff.
- Many prefixes coexist with LRU-style eviction, so interleaved clients with different prefixes don't destroy each other.
- Shape-agnostic: one-shot requests, multi-turn chats, tool loops — anything — reuse via the same raw token-LCP check, no message-structure gates.
- Branching is a copy:
seq_cplets forked conversations (regeneration, speculative decoding) share parent KV without re-prefill. - Correctness model: the LCP token match at request time is the only source of truth, with full-prefill fallback on any mismatch — reuse is never load-bearing.
FLM's wrapper layer is already well-aligned with this model: _shared_insert()'s token-prefix walk and the full-prefill fallback are the same safety-net philosophy. What's missing is entirely in the engine API.
Requested changes (in order of impact)
- A token-range truncate/rewind primitive on the engine — e.g.
truncate(len)(a logical cursor move + cell invalidation is enough; attention already reads only below the current length). This single primitive enables: partial-prefix reuse on mid-prompt divergence, checkpoint placement inside prefills, and forked-conversation support at the wrapper level. - Multiple checkpoint slots (save/restore by handle, or several named pins) — enables several distinct stable prefixes and pinned system prompts to coexist across interleaved clients.
- Longer term: an LCP-based cache in
_shared_insert— on divergence, keep KV for the longest common token prefix instead of clearing, with a small LRU over cached prompts (the llama-server design). This is the general solution and would make caching shape-agnostic for all clients. - Smaller hardening items observed along the way:
- Centralize the per-wrapper checkpoint bookkeeping (
restore_allowed → restore(); token_history = checkpoint_his+checkpoint_his = token_history; engine->checkpoint()is duplicated in every model wrapper, with a documented silent-desync trap). One shared hook pair inAutoModelwould make engine cache features far cheaper to add across models. - Make the
restore_allowedpath verify consistency (e.g. comparerestore()'s returned length againstcheckpoint_his.size()) so out-of-band checkpointing degrades to a cache miss instead of silent corruption.
- Centralize the per-wrapper checkpoint bookkeeping (
Expected impact
For prefix-heavy serving, per-request prefill drops from the full shared prefix to just the new payload — in our measurements from ~1,900 tokens to ~35 (wall time ~7.7 s → ~4.0 s on qwen3:8b, generation-bound; prefill-bound workloads gain proportionally more), with outputs identical to cold inference.
Batch/RAG/eval workloads would see the largest wins: effectively full-prefill cost only for the first request, near-free reuse after.
Operating System
No response
GPU
No response
ROCm Component
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing PromptCache::can_use_cache(), _shared_insert(), and the causal_lm checkpoint/restore interface to confirm the current cache and divergence behavior. Done means the engine exposes the requested rewind capability and the wrapper can reuse the longest shared token prefix for one-shot requests without changing cold-inference outputs; identify the relevant tests while tracing these entry points.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- ai-infra-agents, backend-api-design, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100