Prefix caching by hash (vLLM)
vllm/v1/core/kv_cache_utils.pyvllm/v1/core/block_pool.pybenchmarks/benchmark_prefix_caching.py
a556f3f · sglang 7d89325Every request to your chat endpoint carries the same 2,000-token system prompt. On an H100 running Llama-3-8B that is 33.2 TFLOP of prefill per request that the GPU has already done — and, without prefix caching, will do again, and again, at twenty requests per second. vLLM's answer is a chained hash over token blocks. This chapter is the mechanism, exactly as implemented, including the parts that are sharper than they look.
The problem
Take a realistic deployment: an agent product, Llama-3-8B, a system prompt of 2,000 tokens (tool schemas, formatting rules, safety preamble), and a user turn averaging 200 tokens. Every request is a fresh HTTP POST. The engine sees 2,200 prompt tokens and prefills all of them.
Using the per-token FLOP formula from FORMULAS, $\text{FLOPs}_{\text{tok}} \approx 2P + 4\,L\,h\,d_h\,s$, the cost of prefilling a prompt of $S$ tokens is the sum over positions $s = 0 \dots S-1$:
where $P$ is parameter count, $L$ layers, $h$ query heads, $d_h$ head dimension. For Llama-3-8B ($P = 8.03 \times 10^9$, $L = 32$, $h = 32$, $d_h = 128$):
| Case | Weight GEMMs | Attention | Total |
|---|---|---|---|
| Full prefill, $S=2200$ | 35.33 TFLOP | 1.27 TFLOP | 36.60 TFLOP |
| Shared prefix alone, $S=2000$ | 32.12 TFLOP | 1.05 TFLOP | 33.17 TFLOP |
| Tail only, 200 tokens on 2,000 cached | 3.21 TFLOP | 0.22 TFLOP | 3.43 TFLOP |
So 90.6% of the prefill FLOPs are redundant, and the reduction from caching the shared prefix is $36.60 / 3.43 = 10.7\times$. Convert that to time on §0.4's reference hardware — H100 SXM, 989.4 TFLOP/s bf16 peak — at a prefill MFU of 45%, so $4.45 \times 10^{14}$ FLOP/s achieved:
The third number is the one that should alarm you. At 20 requests per second the redundant prefill alone demands 664 TFLOP/s; the H100 at 45% MFU delivers 445 TFLOP/s in total. The machine is saturated by work it has already done and the queue grows without bound. This is not an optimisation; it is the difference between a system that serves the load and one that does not.
45% is a plausible prefill MFU for a large batched GEMM, not a measurement. The 200-token tail prefill is a much smaller GEMM and will achieve less, so the real end-to-end speedup is below $10.7\times$. Both TTFT figures share the assumption, so the ratio is more trustworthy than either absolute. Measure it in Lab 04.
Mental model
The KV cache is already paged into fixed-size blocks of block_size tokens
(§2.2 owns the block pool). Prefix caching adds one idea:
give each full block a content-addressed name, and keep a dictionary from name to block.
When a new request arrives, compute the names its blocks would have, look them up, and
adopt any block that already exists.
The trick is what goes into the name. Hashing only the block's own 16 token ids would be
catastrophically wrong: the same 16 tokens appearing after "Ignore all prior
instructions." and after "You are a helpful assistant." have
completely different K and V, because attention at position 20 depends on positions 0–19.
Same name, different contents — the engine would serve one request's KV to another.
So a block's name is a hash of its parent's name together with its own token ids. That single recursion makes the name a fingerprint of the entire prefix ending at that block boundary. Matching prefixes and all cache-identity inputs produce the same hash in the same namespace; equal hashes do not logically prove equal inputs because collisions are possible.
Figure 1 — the hash chain. Two requests share a 32-token prefix, then diverge. Block hashes are identical while the chain is identical and go uncorrelated the instant one token differs. Token ids are illustrative.
First principles: the chain, the keys, the granularity
The hash function
Twenty-eight lines contain the whole idea:
def hash_block_tokens(
hash_function: Callable[[Any], bytes],
parent_block_hash: BlockHash | None,
curr_block_token_ids: Sequence[int],
extra_keys: tuple[Any, ...] | None = None,
) -> BlockHash:
"""Computes a hash value corresponding to the contents of a block and
the contents of the preceding block(s). The hash value is used for
prefix caching. We use LRU cache for this function to avoid recomputing
hash values for the same block contents.
# ...
"""
if not parent_block_hash:
parent_block_hash = NONE_HASH
curr_block_token_ids_tuple = tuple(curr_block_token_ids)
return BlockHash(
hash_function((parent_block_hash, curr_block_token_ids_tuple, extra_keys))
)
Define $t_i$ as the token-id tuple of block $i$, $e_i$ as its extra keys, and $H$ as the configured hash function. Then
a Merkle-style chain: $h_i$ fingerprints the entire token sequence $[0, (i{+}1)\cdot B)$ plus
every extra key along the way, where $B$ is the block size. This is the correctness property the
whole feature rests on. Without the parent term, $h_i$ would depend only on $t_i$, and any two
prompts whose $i$-th block happened to hold the same 16 tokens would be judged interchangeable —
not a rare coincidence when "\n\nAssistant: ", JSON punctuation runs, and few-shot
separators recur at block granularity constantly. You would serve one user's attention state to
another within seconds of startup.
NONE_HASH is the chain's initialisation vector, and its choice is a documented
security decision:
# The hash seed for the first block of any prefix block sequence.
#
# For cryptographic hash algorithms it is derived deterministically from a fixed
# default seed, so independent vLLM processes compute identical block hashes for
# identical content and can share a prefix cache (e.g. KV cache reuse across
# nodes) without extra configuration. This does not weaken collision resistance,
# which for SHA-256 does not depend on keeping the seed secret; ``cache_salt``
# remains the mechanism for intentional cache isolation.
#
# Non-cryptographic algorithms keep a per-process random seed, because a
# predictable seed would let an attacker precompute colliding blocks offline
# (see #12621). Setting PYTHONHASHSEED overrides the seed in both cases.
#
# The function `init_none_hash` initializes this variable globally.
NONE_HASH: BlockHash
# Fixed seed used when the PYTHONHASHSEED environment variable is not set and
# the hash algorithm is cryptographic.
DEFAULT_NONE_HASH_SEED = "vllm-none-hash"
What else is in the key
Anything that changes a block's KV tensors but not its token ids must be in the key, or the
cache is unsound. As of a556f3f the complete list is four items:
mm_extra_keys: list[Any]
mm_extra_keys, new_start_mm_idx = _gen_mm_extra_hash_keys(
request, start_token_idx, end_token_idx, start_mm_idx
)
lora_extra_keys: list[str] = _gen_lora_extra_hash_keys(request)
cache_salt_keys: list[str] = (
[request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else []
)
prompt_embeds_keys = _gen_prompt_embeds_extra_hash_keys(
request, start_token_idx, end_token_idx
)
extra_keys: list[Any] = (
lora_extra_keys + mm_extra_keys + cache_salt_keys + prompt_embeds_keys
)
if not extra_keys:
return None, new_start_mm_idx
return tuple(extra_keys), new_start_mm_idx
lora_request.lora_name
Different adapters produce different K and V from identical tokens. Two adapters served concurrently keep disjoint prefix caches over the same system prompt.
(mm_feature.identifier, offset - start_token_idx)
Image and audio placeholder tokens are identical token ids regardless of the pixels behind them. The feature identifier plus its offset within the block separates them.
request.cache_salt
Applied only when start_token_idx == 0. The chain propagates it to every later
block for free: one entry, whole-request isolation.
SHA-256 of the embedding slice
When the caller supplies embeddings rather than token ids the tokens are meaningless, so the tensor bytes are hashed per block and memoised on the request.
This list is the security-and-correctness boundary of the feature: anything that must not
collide has to appear here. Note what is absent — sampling parameters (correctly; they do
not affect KV) and model weights, which is why reset_prefix_cache() exists for RLHF
weight updates.
Why block granularity, not token
The block is the unit of allocation, so it is also the unit of sharing: you cannot hand a request the first 31 tokens of a 32-token physical block without handing it the whole block, nor let two requests own overlapping halves while one keeps appending. With $B = 16$:
| Matching prefix (tokens) | Blocks hit | Tokens reused | Wasted match |
|---|---|---|---|
| 31 | 1 | 16 | 15 |
| 32 | 2 | 32 | 0 |
| 2000 | 125 | 2000 | 0 |
| 2007 | 125 | 2000 | 7 |
Reuse is $B \lfloor m / B \rfloor$ for a token-level match of length $m$, so the loss is $m \bmod B$ — at most $B-1$, averaging 7.5 tokens for $B = 16$. Against a 2,000-token shared prefix that is 0.4%, irrelevant.
The alignment sensitivity is the part that is not irrelevant. Hit length is not "longest common prefix rounded down"; it is "longest common prefix starting at token 0, rounded down". Prepend one token — a per-user id, a timestamp, a request uuid — and block 0's token ids change, $h_0$ changes, and by the chain every $h_i$ changes. You do not lose one block. You lose all 125.
Figure 2 — alignment sensitivity. The same 2,000-token system prompt, shifted by one token. Block boundaries no longer coincide with any cached block, and the chained hash makes the first mismatch fatal for everything after it.
How vLLM implements it
Prefix caching is on by default at a556f3f, SHA-256 over a pickle
serialisation:
enable_prefix_caching: bool = True
"""Whether to enable prefix caching."""
prefix_caching_hash_algo: PrefixCachingHashAlgo = "sha256"
"""Set the hash algorithm for prefix caching:
- "sha256" uses Pickle for object serialization before hashing. This is the current
default, as SHA256 is the most secure choice to avoid potential hash collisions.
- "sha256_cbor" provides a reproducible, cross-language compatible hash. It
serializes objects using canonical CBOR and hashes them with SHA-256.
- "xxhash" uses Pickle serialization with xxHash (128-bit) for faster,
non-cryptographic hashing. Requires the optional ``xxhash`` package.
Hashing happens on the request, not in the block manager
The hashes are computed by the Request object itself, incrementally, whenever a
new full block's worth of tokens exists — at construction for the prompt, and again on every
append_output_token_ids:
new_block_hashes: list[BlockHash] = []
while True:
end_token_idx = start_token_idx + hash_block_size
if end_token_idx > num_tokens:
# We only hash full blocks
break
# MM and LoRA requests need extra keys for block-hash computation.
extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
request, start_token_idx, end_token_idx, curr_mm_idx
)
# Compute the hash of the current block
block_tokens = request.all_token_ids[start_token_idx:end_token_idx]
block_hash = hash_block_tokens(
caching_hash_fn, prev_block_hash_value, block_tokens, extra_keys
)
new_block_hashes.append(block_hash)
start_token_idx += hash_block_size
prev_block_hash_value = block_hash
return new_block_hashes
We only hash full blocks: a partially filled tail block has no hash and cannot be
shared. Generated tokens are hashed exactly like prompt tokens, which is what makes multi-turn
reuse work — turn 2's prompt is turn 1's prompt plus turn 1's output, and those blocks are already
named.
The lookup
The map is a plain dict from a 36-byte key (32-byte SHA-256 digest plus a 4-byte KV-cache group id) to a block:
def get_cached_block(
self, block_hash: BlockHash, kv_cache_group_ids: list[int]
) -> list[KVCacheBlock] | None:
# ...
cached_blocks = []
for group_id in kv_cache_group_ids:
block_hash_with_group_id = make_block_hash_with_group_id(
block_hash, group_id
)
block = self.cached_block_hash_to_block.get_one_block(
block_hash_with_group_id
)
if not block:
return None
cached_blocks.append(block)
return cached_blocks
The prefix scan stops at the first missing physical block because this API returns a contiguous reusable prefix. A token or identity change changes all descendant hashes. Physical eviction is different: it removes an index entry, not the deterministic hashes of later blocks, which may remain stored but cannot fill the ancestor hole. The source comment below compresses these two different reasons for a miss.
computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
[] for _ in range(len(kv_cache_group_ids))
)
# Phase 1: longest run of cached full blocks from the start. A missing
# block implies every later block misses too (chained hashes).
for block_hash in itertools.islice(full_block_hashes, max_length // block_size):
cached_block = block_pool.get_cached_block(block_hash, kv_cache_group_ids)
if not cached_block:
break
for computed, cached in zip(computed_blocks, cached_block):
computed.append(cached)
hit_length = len(computed_blocks[0]) * block_size
Per request the complexity is $O(S)$ to hash (amortised, incremental, once per token) and $O(S/B)$ expected-$O(1)$ dict probes to look up, terminating at the first miss. Insertion is $O(1)$ per newly full block. There is no search, no comparison against other stored sequences, and no structure larger than a dict. For Llama-3-8B on an 80 GB H100, whose KV pool is 52.32 GiB (§2.1, derived), the map holds at most $52.32\,\text{GiB} / 2\,\text{MiB} = 26{,}785$ entries — at 36 raw key bytes per entry, under a megabyte of key payload. Python objects, dictionaries, reference counts, and allocator overhead make the full index larger.
Eviction and the conditions for inexpensive resumption
A cached block is not pinned. It sits in the free queue as an eviction candidate with
ref_cnt == 0, still carrying its hash and still findable, and loses that hash only at
the moment it is handed to someone else, inside get_new_blocks →
_maybe_evict_cached_block. The eviction order is set on free:
def free_blocks(self, ordered_blocks: Iterable[KVCacheBlock]) -> None:
"""Free a list of blocks. The blocks should be ordered by their
eviction priority, where the first block will be evicted first.
# ...
# Identify blocks with hash (LRU cache) and without it (never match APC)
blocks_to_evict_last = []
blocks_to_evict_first = []
for block in ordered_blocks:
block.ref_cnt -= 1
if block.ref_cnt == 0 and not block.is_null:
if block.block_hash is None or not self.enable_caching:
# LIFO reuse of non-cached blocks for better GPU locality.
blocks_to_evict_first.append(block)
else:
# FIFO reuse of cached blocks for LRU eviction behavior.
blocks_to_evict_last.append(block)
# Blocks to reuse first are prepended to the front of the free queue.
self.free_block_queue.prepend_n(blocks_to_evict_first)
# Blocks to reuse last are appended to the end of the free queue.
self.free_block_queue.append_n(blocks_to_evict_last)
Two policies in one queue: unhashed blocks to the front (LIFO, reuse hot memory at once),
hashed blocks to the back (FIFO, so allocation pops the least-recently-freed cached block
first). Within a request, KVCacheManager.free walks blocks in reverse, so tail blocks
land nearer the front than head blocks — the ordering documented on the queue itself:
The queue is ordered by block ID in the beginning. When a block is allocated
and then freed, it will be appended back with the eviction order:
1. The least recent used block is at the front (LRU).
2. If two blocks have the same last accessed time (allocated by the
same sequence), the one with more hash tokens (the tail of a block
chain) is at the front.
Note that we maintain this order by reversing the block order when free
blocks of a request. This operation is outside of this class.
This is the mechanism §1.4 relies on. A preempted request's blocks are freed with their hashes intact and appended to the tail of the queue. Unless the pool churns through the entire free list before the victim is re-admitted, the victim's own hashes still resolve and its "recompute from scratch" is a prefix-cache hit for almost everything. Hash-keyed blocks surviving eviction is what turns preemption from a catastrophe into a scheduling hiccup.
Rule 2 is a crude approximation of tree-order eviction: deeper blocks go first, because a shallow block is likelier to be a shared prefix. But it holds only within one request's free operation. Across requests, ordering is pure LRU over free times, with no knowledge of which block is a parent of which.
Worked trace: one request through the cache
Two requests, same 2,000-token system prompt, block_size = 16, single KV cache
group, Llama-3-8B.
Figure 3 — the lookup path. Real function names in call order, from request construction to the block table the model runner reads.
Request 1, cold cache. Request.__init__ calls
update_block_hashes(), which runs the closure from
get_request_block_hasher over 2,200 prompt tokens: $\lfloor 2200/16 \rfloor = 137$
full blocks, 137 chained SHA-256 evaluations, 8 tokens left unhashed. The map is empty, the first
probe misses, hit length is 0, and the request prefills all 2,200 tokens. As blocks fill,
cache_full_blocks inserts each hash into cached_block_hash_to_block.
Request 2, same system prompt, different user turn. Its first 125 block hashes are
bit-identical to request 1's, because tokens 0–1999 are identical and the chain is deterministic.
find_longest_cache_hit probes block 0 (hit), 1 (hit), … 124 (hit), 125 (miss — the
user turn diverges), giving hit_length = 125 × 16 = 2000.
BlockPool.touch bumps ref_cnt on all 125 and pulls each out of the free
queue. The scheduler prefills 200 tokens instead of 2,200.
The exact-duplicate case. Send request 1 twice. Now every hash matches — but look at the cap:
if not self.prefix_cache_lookup_enabled(request):
return self.empty_kv_cache_blocks, 0, 0
# NOTE: When all tokens hit the cache, we must recompute the last token
# to obtain logits. Thus, set max_cache_hit_length to prompt_length - 1.
# This can trigger recomputation of an entire block, rather than just
# the single last token, because allocate_slots() requires
# num_computed_tokens to be block-size aligned. Removing this limitation
# could slightly improve performance in the future.
max_cache_hit_length = request.num_tokens - 1
computed_blocks, num_new_computed_tokens, num_uncached = (
self.coordinator.find_longest_cache_hit(
request.block_hashes, max_cache_hit_length
)
)
With 2,200 tokens, max_cache_hit_length = 2199 bounds the loop at
$\lfloor 2199/16 \rfloor = 137$ — all of them — so the duplicate hits 2,192 tokens and prefills 8.
But for a prompt that is an exact multiple of the block size (2,000 tokens, 125 blocks),
max_cache_hit_length = 1999, $\lfloor 1999/16 \rfloor = 124$, and the engine recomputes
a full 16-token block to obtain logits for one token. This is the most confusing observation
for people first reading the hit-rate metric: a perfectly cached prompt reports 124/125.
Pitfalls, security, and what invalidates a hit
What invalidates a hit
a556f3f.| Cause | Effect | Where |
|---|---|---|
| Any token differs at index $j$ | All blocks from $\lfloor j/B \rfloor$ onward miss | the chain, hash_block_tokens |
| Different LoRA adapter | All blocks miss | _gen_lora_extra_hash_keys |
Different cache_salt | All blocks miss (first block keyed, chain propagates) | generate_block_hash_extra_keys |
| Different image, same placeholder tokens | The block containing the changed multimodal identity and all subsequent chained blocks miss | _gen_mm_extra_hash_keys |
| Block evicted and reallocated | Evicted block loses its index entry; descendant hashes may remain, but longest-contiguous-prefix lookup stops at the missing ancestor | _maybe_evict_cached_block |
prompt_logprobs set on the request | Lookup skipped entirely for this request | SamplingParams, skip_reading_prefix_cache |
reset_prefix_cache() (RLHF weight update) | Whole map cleared, every hash reset | BlockPool.reset_prefix_cache |
The prompt_logprobs case surprises people: turn it on for observability and the hit
rate for those requests goes to zero, by design —
if self.skip_reading_prefix_cache is None:
# If prefix caching is enabled,
# the output of prompt logprobs may less than n_prompt_tokens,
# we need to skip reading cache at this request.
self.skip_reading_prefix_cache = self.prompt_logprobs is not None
And reset_prefix_cache is a request, not a command — it fails if anything is still
allocated, and logs it:
num_used_blocks = self.num_gpu_blocks - self.get_num_free_blocks()
if num_used_blocks != 1: # The null block is always marked as used
logger.warning(
"Failed to reset prefix cache because some "
"blocks (%d) are not freed yet",
num_used_blocks - 1,
)
return False
# Remove all hashes so that no new blocks will hit.
self.cached_block_hash_to_block = BlockHashToBlockMap()
self.cached_block_hashes_by_block.clear()
# Remove all hashes from all blocks.
for block in self.blocks:
block.reset_hash()
The security question, honestly
A shared prefix cache across tenants leaks two things.
Timing. A hit drops TTFT by tens of milliseconds — 74 ms in the derivation above, trivially observable over the network. An attacker who can time requests can therefore test whether a given prefix has recently been served by somebody, and search over candidate prefixes at block granularity. This is inherent to any cross-request cache; no hash function fixes it.
Content, on collision. get_cached_block returns a block on hash equality
alone. It does not re-verify token ids. A collision therefore serves one request's KV as
another's, silently — wrong output, no error. With SHA-256 and a map bounded at ~27k live entries
this is not a practical concern: the birthday bound over $n$ blocks is $\approx n^2/2^{257}$, about
$10^{-59}$ at $n = 10^9$. xxhash (128-bit) raises that to roughly $10^{-21}$ — still
negligible by accident, but no longer collision-resistant against an adversary who chooses
inputs, which is exactly why the non-cryptographic path keeps a random per-process
NONE_HASH seed (issue #12621).
Cache namespaces and collision resistance address different risks. Assign cache_salt at a trusted authenticated gateway for tenant/user isolation; a caller-controlled salt does not enforce an authorization boundary. Use a suitable cryptographic hash where adversarial collisions matter. Salting alone does not turn a non-cryptographic hash into a collision-resistant one.
cache_salt: str | None = Field(
default=None,
min_length=1,
description=(
"If specified, the prefix cache will be salted with the provided "
"string to prevent an attacker to guess prompts in multi-user "
"environments. The salt should be random, protected from "
"access by 3rd parties, and long enough to be "
"unpredictable (e.g., 43 characters base64-encoded, corresponding "
"to 256 bit)."
),
)
The residual risk is the tradeoff you are actually making. A salt per user buys per-user isolation and keeps per-user reuse; a salt per request buys full isolation and destroys the feature; a salt shared across a tenant leaves that tenant's members able to probe each other. No configuration gives you both cross-user prefix sharing and cross-user timing isolation.
Metrics you can actually read
Two Prometheus counters, both in tokens (not requests):
counter_prefix_cache_queries = self._counter_cls(
name="vllm:prefix_cache_queries",
documentation=(
"Prefix cache queries, in terms of number of queried tokens."
),
labelnames=labelnames,
)
# ...
counter_prefix_cache_hits = self._counter_cls(
name="vllm:prefix_cache_hits",
documentation=("Prefix cache hits, in terms of number of cached tokens."),
labelnames=labelnames,
)
Hit rate is rate(vllm:prefix_cache_hits[5m]) / rate(vllm:prefix_cache_queries[5m]).
Requests that skipped the lookup are excluded from both, so the denominator is honest.
PrefixCacheStats also splits out preempted requests:
preempted_requests: int = 0
"""The number of previously preempted requests in this update."""
preempted_queries: int = 0
"""The `queries` number for preempted requests."""
preempted_hits: int = 0
"""The `hits` number for preempted requests."""
def record(self, num_tokens: int, num_hits: int, preempted: bool) -> None:
"""Aggregate request information into the stats."""
if preempted:
# Previously preempted request
self.preempted_requests += 1
self.preempted_queries += num_tokens
self.preempted_hits += num_hits
else:
# New request
self.requests += 1
self.queries += num_tokens
self.hits += num_hits
preempted_hits / preempted_queries is exactly the survival fraction $f$ that
§1.4 needs for the recompute-versus-swap
decision. If it drops well below 1.0, your pool is churning through the whole free queue between
preemption and resumption, and recompute is no longer cheap.
What a hash map cannot do
§2.4 covers SGLang's RadixAttention, which replaces the dict with a radix tree over token sequences. To make that comparison sharp, here is what vLLM's structure does and does not give you.
a556f3f.| Property | vLLM as implemented |
|---|---|
| Match granularity | Whole blocks, aligned to token 0. Loses up to $B-1$ tokens per match; loses everything on a misaligned shift. |
| Match semantics | Longest block-aligned prefix of this request's own chain. Answers "do you have exactly this?", not "what is the closest thing you have?" |
| Lookup cost | $O(S/B)$ dict probes, early exit at first miss. No pointer chasing, no comparisons. |
| Insert cost | $O(1)$ per full block, at the moment the block fills. |
| Structure awareness | None. The map cannot enumerate what is cached, cannot tell you which stored prefixes branch where, and has no parent-child edges. |
| Eviction ordering | Global LRU over free-time plus a per-request tail-first heuristic. Evicting a shallow block orphans its descendants: their hashes still resolve, but no request can reach them without first matching the now-missing parent. |
| Cache-aware scheduling | Not built in. Ordering the waiting queue by cache affinity means probing per candidate; the map offers no cheaper signal. |
The last two rows are the substantive ones. A tree knows that node $X$ is the parent of node $Y$, so it can evict leaves before internal nodes and rank a waiting request by match depth without touching the block pool. A hash map knows neither. What it buys instead is a lookup with no allocations, no rebalancing, no locks around a mutable structure, and low expected probe cost. Hash construction, CPU contention, and long requests can still make lookup measurable; profile rather than assume. Which side of that trade is correct depends on your workload — the argument §2.4 takes up.
At this SHA there is a sub-block "fine-grained" lookup path in
FullAttentionManager.find_longest_cache_hit (phase 2, probing interior hash
boundaries when alignment_tokens < block_size). It is reachable only for hybrid KV
cache configurations where groups have different block sizes;
UnitaryKVCacheCoordinator asserts hash_block_size == block_size
(vllm/v1/core/kv_cache_coordinator.py:L516-L518), so for the ordinary single-group
model the granularity really is one block.
Hands-on
Measure the hashing cost itself, no GPU required:
python benchmarks/benchmark_prefix_block_hash.py --num-blocks 20000 --block-size 16
# compares sha256, sha256_cbor, xxhash, xxhash_cbor over hash_block_tokens
Then the end-to-end effect. benchmarks/benchmark_prefix_caching.py (277 lines, the
real implementation, not a shim) replicates each sampled prompt --repeat-count times:
python benchmark_prefix_caching.py \
--model meta-llama/Llama-2-7b-chat-hf \
--enable-prefix-caching \
--num-prompts 1 \
--repeat-count 100 \
--input-length-range 128:256
Three things to try, in order. Serve with caching on, scrape /metrics, and confirm
the hits/queries ratio matches the shared fraction of your prompt. Then add
a fresh unique cache_salt value to every request body and watch the hit rate
collapse to zero — the isolation mechanism working. Reusing one fixed new salt gives only an initial cold miss and allows later reuse. Then prepend a single per-request token and
confirm the hit rate collapses identically. Confusing those two failure modes in
production costs hours. Lab 04 does all three against both engines.
Exercises
- Read and answer. Open
vllm/v1/core/kv_cache_utils.pyand findgenerate_block_hash_extra_keys. Why iscache_saltgated onstart_token_idx == 0while the LoRA name is not? What would break if the gate were removed fromcache_salt? What would break if the gate were added to LoRA?Answer
The salt only needs to enter the chain once: $h_0$ depends on it and every $h_i$ depends on $h_{i-1}$, so isolation propagates for free. Salting every block would be correct but wasteful. Gating LoRA on block 0 would also still be correct for a single request — the chain carries it — but it would break any consumer that treats one block hash as identifying its own content, and would make the KV-event
extra_keys_listinconsistent across blocks. Keying every block keeps the hash self-describing. - Arithmetic.
block_size = 32, two requests share a 4,097-token prefix. How many tokens are reused? Now the operator switches toblock_size = 16. How many? What is the general expression, and what does it say about choosing block size for prefix reuse?Answer
$32 \lfloor 4097/32 \rfloor = 4096$ tokens, and $16 \lfloor 4097/16 \rfloor = 4096$ as well: both lose the same single token, because 4096 is divisible by both. In general reuse is $B\lfloor m/B \rfloor$, loss is $m \bmod B \in [0, B-1]$, expected $(B-1)/2$. Smaller $B$ strictly improves reuse granularity — and costs more block-table entries, more hash evaluations, and shorter contiguous runs for the attention kernel. That kernel-side cost is why $B = 16$ rather than $B = 1$.
- Predict, then verify. A 1,024-token prompt is sent twice in a row with
block_size = 16, nothing else running. Predictvllm:prefix_cache_hitsandvllm:prefix_cache_queriesafter the second request. Then run it and check.Answer
Queries increments by 1,024 both times (it records
request.num_tokens); hits is 0 for the first. For the second,max_cache_hit_length = 1023bounds the loop at $\lfloor 1023/16 \rfloor = 63$ blocks, so hits $= 1008$ and 16 tokens are re-prefilled for logits. Totals: 1008 / 2048 = 0.492. If you predicted 0.5, you missed thenum_tokens - 1cap. - Predict, then verify. You enable
--prefix-caching-hash-algo xxhashacross a fleet of four vLLM replicas behind a load balancer, intending to share prefix-cache blocks over a KV connector. What goes wrong, and what does the log say?Answer
resolve_none_hash_seedreturnsos.urandom(32).hex()for non-cryptographic algorithms unlessPYTHONHASHSEEDis set, so each replica derives a differentNONE_HASHand a completely different chain. No hash from replica A ever matches one from replica B.init_none_hashwarns: "Using a random per-process NONE_HASH seed because %s is not collision resistant. Block hashes are therefore not reproducible across processes; set PYTHONHASHSEED to a shared value to reuse the prefix cache across instances, or use sha256." - Design. Your gateway prepends
"Current time: 2026-08-21T14:03:11Z\n"to every system prompt for grounding. Hit rate is 0. Give two fixes with different tradeoffs, and say what each costs.Answer
(a) Move the timestamp after the static system prompt: the static prefix stays cacheable and only blocks from the timestamp onward miss. Costs nothing but prompt-engineering discipline, and is almost always the right answer. (b) Coarsen the timestamp to hour granularity, so all requests within an hour share it; costs grounding precision and produces a miss cliff at each hour boundary. Padding the timestamp to a multiple of
block_sizedoes not help — the chain, not the alignment, is what breaks. Block 0's token ids still differ.
Key takeaways
- The parent-hash term is not an optimisation, it is the correctness condition. $h_i = H(h_{i-1}, t_i, e_i)$ makes a block's name a fingerprint of its entire prefix; drop it and identical 16-token runs after different contexts become interchangeable, which in a chat workload happens within seconds.
- The extra-keys tuple — LoRA name, multimodal identifier plus in-block offset,
cache_salt, prompt-embedding digest — is the complete list of things that change KV without changing token ids. It is the soundness boundary; anything added to the engine that alters K/V must join it. - Reuse is $B\lfloor m/B\rfloor$ tokens for a token-level match of $m$. Losing up to 15 tokens is irrelevant; losing alignment is fatal. A one-token prepend takes a 1,999-token match to a zero-token hit, and the metric can look like a namespace change from a fresh salt, not a hash collision.
- Cached blocks are eviction candidates that keep their hash until the moment they are handed out. That is why a preemption victim resumes with a near-total hit — and why
preempted_hits / preempted_queriesis the metric that tells you whether recompute is still cheaper than swap. - A fully cached prompt reports a hit of $B\lfloor (S-1)/B \rfloor$, not $S$: the last token must be recomputed for logits, and block alignment rounds that up to a whole block.
- Cross-request prefix caching leaks timing to anyone who can measure TTFT.
cache_salttrades reuse for isolation at whatever granularity you choose; there is no setting that gives you both.
Further reading
- vLLM PR #9972 — V1 Prefix caching (take 2): the V1 rewrite that introduced the chained block hash and the free-queue-as-LRU design.
- vLLM PR #10507 — Refactor KVCacheManager for more hash input than token ids: where
extra_keyswas generalised, enabling LoRA and multimodal keying. - vLLM PR #11187 — Prefix caching for vision language models: the multimodal identifier and the in-block offset.
- vLLM PR #17045 — Prevent side-channel attacks via cache salting, and issue #12621 on precomputed-collision risk with non-cryptographic hashes.
- vLLM PR #29163 — xxHash as a high-performance hash option, and PR #51875 — deterministic NONE_HASH by default, which made cross-process cache sharing work without
PYTHONHASHSEED. - §2.4 — RadixAttention for the tree, and §12.3 for its implementation.
- Lab 04 — prefix cache hit rate: run a shared-prefix workload against both engines and break the cache deliberately.