v1/core/kv_cache_utils.py: block hashes, KVCacheBlock and the free queue
Code version: vLLM @ 3e44c0d · 2026-10-22
Purpose#
vllm/v1/core/kv_cache_utils.py is the toolbox under the block pool and the single-type managers. It holds data types, the hash chain, and small helpers for the start-up of the engine core.
| File | vllm/v1/core/kv_cache_utils.py, 1,217 lines of Python. Four classes and 28 top-level functions. |
|---|---|
| Main classes | KVCacheBlock (vllm/v1/core/kv_cache_utils.py:161-224), FreeKVCacheBlockQueue (vllm/v1/core/kv_cache_utils.py:258-519), BlockHashListWithBlockSize (vllm/v1/core/kv_cache_utils.py:1111-1181), KVCacheBlockCopy (vllm/v1/core/kv_cache_utils.py:253-255). |
| Runs in | The EngineCore process. The request hasher runs on its input thread when a request arrives, and on the main thread when tokens are appended. Some KV connectors also call the block-size helpers in the workers. CPU only. |
| Called by | EngineCore at start-up, Request, BlockPool, the single-type managers, KVCacheManager, the scheduler, and many KV connectors (29 files in vllm/ import it). |
| Calls | The xxhash functions of vllm/utils/hashing.py (only to tell them apart), get_mm_features_in_window, KVCacheSpecRegistry and the spec classes of kv_cache_interface.py (vllm/v1/core/kv_cache_utils.py:21-37). |
| Not in this file | The hash functions themselves (vllm/utils/hashing.py), the block pool and the hash map (block_pool.py), the hit search (single-type managers), the grouping and memory planning (kv_cache_planning.py). |
Where it sits#
Who calls it#
EngineCoreresolves the block sizes, makes the hasher, and logs the capacity (vllm/v1/engine/core.py:57-65,vllm/v1/engine/core.py:173-175,vllm/v1/engine/core.py:216-225,vllm/v1/engine/core.py:324-354).Requestcalls the hasher in its constructor and after each new token (vllm/v1/request.py:225-230,vllm/v1/request.py:285-288).BlockPoolmakes allKVCacheBlocks and the free queue, and uses the hash-key helpers (vllm/v1/core/block_pool.py:16-29,vllm/v1/core/block_pool.py:172-178).- The single-type managers use
resolve_block_hashes,BlockHashListWithBlockSizeandtrim_null_head(vllm/v1/core/single_type_kv_cache_manager.py:13-20). - The workers use
KVCacheBlockCopyfor copy-on-write (vllm/v1/worker/utils.py:30). A P2P offload tier reads theNONE_HASHseed (vllm/v1/kv_offload/tiering/p2p/manager.py:714).
What it calls#
xxhashandxxhash_cbor, only to know which hash functions are not cryptographic (vllm/v1/core/kv_cache_utils.py:110-111).get_mm_features_in_windowto find the first image that touches a block (vllm/v1/core/kv_cache_utils.py:976-983).KVCacheSpecRegistry.get_manager_classto check if every group supports fine-grained hits (vllm/v1/core/kv_cache_utils.py:936-942).envs.VLLM_KV_EVENTS_USE_INT_BLOCK_HASHESfor the hash format of KV events (vllm/v1/core/kv_cache_utils.py:82-85).
Key classes and functions#
The file in order. Line ranges are at the pinned commit.
| Lines | Name | What it does |
|---|---|---|
vllm/v1/core/kv_cache_utils.py:44-85 | BlockHash, BlockHashWithGroupId, ExternalBlockHash, pack/unpack helpers | the block hash types; group id as 4 big-endian bytes |
vllm/v1/core/kv_cache_utils.py:90-158 | NONE_HASH, resolve_none_hash_seed, get_none_hash_seed, init_none_hash | the parent hash of block 0 (NONE_HASH) |
vllm/v1/core/kv_cache_utils.py:161-224 | KVCacheBlock | the record of one block (KVCacheBlock) |
vllm/v1/core/kv_cache_utils.py:227-250 | null_head_len, trim_null_head | count or drop the leading null blocks of a block table |
vllm/v1/core/kv_cache_utils.py:253-255 | KVCacheBlockCopy | a (source, destination) pair for copy-on-write |
vllm/v1/core/kv_cache_utils.py:258-519 | FreeKVCacheBlockQueue | the free block queue: a doubly linked list through the blocks |
vllm/v1/core/kv_cache_utils.py:522-675 | _gen_mm_extra_hash_keys, _gen_lora_extra_hash_keys, _gen_prompt_embeds_extra_hash_keys, generate_block_hash_extra_keys | the extra keys of one block |
vllm/v1/core/kv_cache_utils.py:678-704 | to_event_extra_keys | extra keys without tags, for KV events |
vllm/v1/core/kv_cache_utils.py:707-737 | hash_block_tokens | one link of the hash chain |
vllm/v1/core/kv_cache_utils.py:740-813 | resolve_dcp_kv_block_size, resolve_dcp_kv_cache_spec, partial_hash_hits_enabled, eagle_proof_margin | block sizes under decode context parallelism; fine-grained hits; EAGLE margin |
vllm/v1/core/kv_cache_utils.py:816-950 | resolve_kv_cache_block_sizes, resolve_cache_hit_alignment_tokens | scheduler block size, hash block size and hit alignment for one or several groups |
vllm/v1/core/kv_cache_utils.py:953-1012 | get_request_block_hasher | the closure that hashes the new full blocks of a request |
vllm/v1/core/kv_cache_utils.py:1015-1108 | get_max_concurrency_for_kv_cache_config, generate_scheduler_kv_cache_config, get_kv_cache_capacity, update_kv_cache_capacity | the scheduler's copy of the KV cache config, and the capacity log line |
vllm/v1/core/kv_cache_utils.py:1111-1184 | BlockHashListWithBlockSize, BlockHashList | a lazy view of fine hashes at a larger block size |
vllm/v1/core/kv_cache_utils.py:1187-1217 | resolve_block_hashes | picks the right hash view for one group |
Walk through the main methods#
KVCacheBlock: one record per block#
@dataclass(slots=True)
class KVCacheBlock:
"""KV-cache block metadata."""
# Block ID, ranging from 0 to num_gpu_blocks - 1.
block_id: int
# Reference count.
ref_cnt: int = 0
# Deferred frees may contain blocks from multiple pools.
pool: "BlockPool | None" = field(
default=None, repr=False, compare=False, kw_only=True
)
# The hash key (block hash + group id) of the block, only available
# when the block is full and cached.
_block_hash: BlockHashWithGroupId | None = None
# Number of prefix tokens covered by _block_hash. For full blocks this is
# the full block boundary; partial entries can end inside a cache block.
_block_hash_num_tokens: int | None = None
# Used to construct a doubly linked list for free blocks.
# These two attributes should only be manipulated by FreeKVCacheBlockQueue.
prev_free_block: "KVCacheBlock | None" = None
next_free_block: "KVCacheBlock | None" = None
# Whether the block is a null block that should never be cached.
is_null: bool = False
@property
def block_hash(self) -> BlockHashWithGroupId | None:
return self._block_hash
@property
def block_hash_num_tokens(self) -> int | None:
return self._block_hash_num_tokens
def set_block_hash(
self,
block_hash: BlockHashWithGroupId,
num_tokens: int | None = None,
) -> None:
assert self.block_hash is None and self._block_hash_num_tokens is None, (
"The block already has a hash. This should not happen."
)
self._block_hash = block_hash
self._block_hash_num_tokens = num_tokens
def reset_hash(self):
"""Reset the block hash when the block is evicted."""
self._block_hash = None
self._block_hash_num_tokens = None- Line 161.
slots=True: no__dict__per block. A large GPU has tens of thousands of these objects. - Lines 166-168. The id indexes the KV tensors of every worker.
ref_cntcounts the requests that hold the block. - Lines 170-172. The pool of the block. A deferred free can hold blocks of two pools (the GPU pool and a HiSparse host pool).
- Lines 175-178. The hash key, set only when the block is cached.
_block_hash_num_tokensis the prefix length that the key covers. It can end inside a block for fine-grained (partial) entries. - Lines 182-183. The two links of the free queue. Only
FreeKVCacheBlockQueuechanges them. - Line 186.
is_nullmarks block 0, the null block. - Lines 201-203. A second hash on the same block is a bug, so the method asserts.
BlockPoolcallsreset_hash()first on eviction.
FreeKVCacheBlockQueue: O(1) remove from the middle#
A Python deque cannot remove an element from the middle in O(1). A prefix-cache hit on a free block must do exactly that. So the queue links the blocks themselves, with two fake ends that are never popped:
def __init__(self, blocks: list[KVCacheBlock]) -> None:
self.num_free_blocks = len(blocks)
# Initialize doubly links of consecutive blocks
for i in range(self.num_free_blocks):
if i > 0:
blocks[i].prev_free_block = blocks[i - 1]
if i < self.num_free_blocks - 1:
blocks[i].next_free_block = blocks[i + 1]
# Create a fake head and a tail block for the doubly linked list to
# reduce branching in the code
#
# The implementation guaranteed that the fake head and tail
# are NEVER got popped, so we could safely assume each real blocks
# in the queue has prev and next blocks.
self.fake_free_list_head = KVCacheBlock(block_id=-1)
self.fake_free_list_tail = KVCacheBlock(block_id=-1)
if self.num_free_blocks > 0:
# Connect fake_head and fake_tail to the first and last block
# respectively.
self.fake_free_list_head.next_free_block = blocks[0]
blocks[0].prev_free_block = self.fake_free_list_head
self.fake_free_list_tail.prev_free_block = blocks[-1]
blocks[-1].next_free_block = self.fake_free_list_tail
else:
# For empty list, simply connect the fake head and tail.
self.fake_free_list_head.next_free_block = self.fake_free_list_tail
self.fake_free_list_tail.prev_free_block = self.fake_free_list_head
def remove(self, block: KVCacheBlock) -> None:
"""Remove a block in the free list and reduce num_free_blocks by 1.
Args:
block: The block to remove.
"""
prev_block = block.prev_free_block
next_block = block.next_free_block
if prev_block is None or next_block is None:
# This should not happen if the block is from the free list.
# It indicates a bug in the caller's logic.
raise RuntimeError(f"remove() called on an invalid block: {block}")
# Link the previous block to the next block.
prev_block.next_free_block = next_block
# Link the next block to the previous block.
next_block.prev_free_block = prev_block
# Remove the block from the linked list.
block.prev_free_block = block.next_free_block = None
self.num_free_blocks -= 1- Lines 285-289. At start-up the queue links the blocks in id order: block 0, 1, 2, ...
- Lines 297-305. Fake head and tail blocks with id −1. Every real block in the queue then has a previous and a next block, so no method needs a special case for the ends.
- Lines 400-403.
remove()on a block that is not in the queue raisesRuntimeError: a caller bug. - Lines 406-412. Two pointer changes and a counter: O(1).
BlockPool.touch()uses it for hits (vllm/v1/core/block_pool.py:766-767).
popleft_n() takes n blocks from the front, prepend_n() puts blocks at the front, and append_n() at the back (vllm/v1/core/kv_cache_utils.py:347-389, vllm/v1/core/kv_cache_utils.py:438-484). None of them makes a Python object; they only change the links (docstring, vllm/v1/core/kv_cache_utils.py:258-279).
get_request_block_hasher: the chain for one request#
The engine core makes the closure once (vllm/v1/engine/core.py:223-225). The basics page reads it line by line (Prefix caching: the idea, deep dive "The hash chain in the code"). The key points:
def request_block_hasher(request: Request) -> list[BlockHash]:
start_token_idx = len(request.block_hashes) * hash_block_size
num_tokens = request.num_tokens
if start_token_idx + hash_block_size > num_tokens:
# Early stop when there no new full blocks created.
return []It starts after the last existing hash and stops before a block that is not full (vllm/v1/core/kv_cache_utils.py:988-993). It returns only the new hashes; Request.update_block_hashes() appends them (vllm/v1/request.py:285-288).
resolve_kv_cache_block_sizes: three sizes for several groups#
cache_config = vllm_config.cache_config
dcp = vllm_config.parallel_config.decode_context_parallel_size
groups = kv_cache_config.kv_cache_groups
if len(groups) <= 1:
if groups and not groups[0].kv_cache_spec.dcp_sharded:
dcp = 1
bs = cache_config.block_size * dcp
# Reject a prefix_match_unit that would otherwise be silently ignored.
if (
cache_config.prefix_match_unit not in (None, bs)
and len(groups) == 1
and (
cache_config.enable_prefix_caching
or vllm_config.kv_transfer_config is not None
)
):
raise ValueError(
f"Invalid prefix_match_unit={cache_config.prefix_match_unit}; "
"with a single KV cache group, prefix-cache hits land on the "
f"block size ({bs}). Unset it or set it to {bs}."
)
return bs, bs
group_block_sizes = [
resolve_dcp_kv_block_size(g.kv_cache_spec, dcp) for g in groups
]
scheduler_block_size = math.lcm(*group_block_sizes)
logger.info_once("kv cache group sizes %s", tuple(group_block_sizes))
logger.info_once("kv lcm block sizes %s", scheduler_block_size)
# Block hashes are only consumed by prefix caching and KV connectors
# (P/D, offloading); when neither is active, keep hash_block_size equal
# to the scheduler block size.
connector_enabled = vllm_config.kv_transfer_config is not None
if not (cache_config.enable_prefix_caching or connector_enabled):
return scheduler_block_size, scheduler_block_size- Lines 840-858. One group: both sizes are the block size (times the decode context parallel size when the cache is sharded). Another
prefix_match_unitraisesValueErrorwhen hashing is active. - Lines 860-863. Several groups: the scheduler works in units of the least common multiple of the group block sizes.
- Lines 871-873. No prefix caching and no connector: nobody reads the hashes, so the hash size equals the scheduler size.
Otherwise the hash size is prefix_match_unit, or the greatest common divisor of the prefix-cacheable group sizes. Every such group size must divide by it (vllm/v1/core/kv_cache_utils.py:885-898). The engine core passes both sizes to the scheduler (vllm/v1/engine/core.py:173-185).
BlockHashListWithBlockSize: coarse hashes for free#
Each fine hash already names its whole prefix. So the hash of a 32-token block is simply the second 16-token hash:
Example (`hash_block_size` = 16, `target_block_size` = 32):
the second 16-size hash already covers tokens 0-31, so it is the 32-size
hash:
Block hashes with block_size 16:
| Token Range | 0-15 | 16-31 | 32-47 | 48-63 |
|-------------|------|-------|-------|-------|
| Hash | A | B | C | D |
Block hashes with block_size 32:
| Token Range | 0-31 | 32-63 |
|-------------|------|-------|
| Hash | B | D | def _get_value_at(self, idx: int) -> BlockHash:
# The last hash_block_size hash within the target block already chains
# over the whole prefix, so it is the target block's hash.
return self.block_hashes[(idx + 1) * self.scale_factor - 1]The view computes nothing ahead of time; it reads one list element per access.
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveData layout of the hash key
| Value | Python type | Bytes (sha256) | Where |
|---|---|---|---|
BlockHash | NewType("BlockHash", bytes) | 32 (16 for xxhash) | vllm/v1/core/kv_cache_utils.py:47 |
BlockHashWithGroupId | bytes: digest + group_id.to_bytes(4, "big") | 36 | vllm/v1/core/kv_cache_utils.py:60-69 |
get_block_hash(key) | key[:-4] | 32 | vllm/v1/core/kv_cache_utils.py:72-74 |
get_group_id(key) | int.from_bytes(key[-4:], "big") | - | vllm/v1/core/kv_cache_utils.py:77-79 |
ExternalBlockHash (events) | bytes | int; by default the low 64 bits as int | 8 as an integer | vllm/v1/core/kv_cache_utils.py:57, vllm/v1/core/kv_cache_utils.py:82-85 |
The comments give the reason for raw bytes: they are compact, and they avoid a tuple object per key (vllm/v1/core/kv_cache_utils.py:49-51, vllm/v1/core/kv_cache_utils.py:65-67).
Deep diveWorked example: the free queue at start-up and after one request
A pool of 6 blocks (ids 0-5), prefix caching on. (Derived from the methods above and vllm/v1/core/block_pool.py:172-188.)
FreeKVCacheBlockQueue([b0..b5]):head ⇄ 0 ⇄ 1 ⇄ 2 ⇄ 3 ⇄ 4 ⇄ 5 ⇄ tail,num_free_blocks = 6.- The pool pops block 0 as the null block:
head ⇄ 1 ⇄ 2 ⇄ 3 ⇄ 4 ⇄ 5 ⇄ tail, 5 free. - A request of 40 tokens with block size 16 takes 3 blocks with
popleft_n(3): blocks 1, 2, 3. Queue:4 ⇄ 5. The links of 1, 2 and 3 areNone. - Blocks 1 and 2 are full and get hashes; block 3 has 8 tokens.
- The request ends. The pool frees
[3, 2, 1]:prepend_n([3]), thenappend_n([2, 1]). Queue:3 ⇄ 4 ⇄ 5 ⇄ 2 ⇄ 1. - A new request hits
h0:remove(block 1)links 2 to the tail. Queue:3 ⇄ 4 ⇄ 5 ⇄ 2.
Deep diveWorked example: block sizes for two groups
Assume a hybrid model with a full-attention group of block size 16 and a sliding-window group of block size 32. Prefix caching is on, there is no decode context parallelism, and --prefix-match-unit is unset. (Derived from vllm/v1/core/kv_cache_utils.py:860-918.)
group_block_sizes = [16, 32], soscheduler_block_size = lcm(16, 32) = 32.- Both groups are prefix-cacheable:
hash_block_size = gcd(16, 32) = 16. 16 and 32 both divide by 16, so no error. - The request hasher makes one hash per 16 tokens. The sliding-window group reads them through
BlockHashListWithBlockSize(scale_factor=2): its hash i is fine hash2i + 1. - The log shows "kv cache group sizes (16, 32)" and "kv lcm block sizes 32" (
vllm/v1/core/kv_cache_utils.py:865-866).
Deep diveError paths and environment variables
| Situation | Result | Where |
|---|---|---|
| A second hash on a block | AssertionError | vllm/v1/core/kv_cache_utils.py:201-203 |
popleft() on an empty queue | ValueError "No free blocks available" | vllm/v1/core/kv_cache_utils.py:320-325 |
remove() or popleft() on a block without links | RuntimeError | vllm/v1/core/kv_cache_utils.py:328-334, vllm/v1/core/kv_cache_utils.py:400-403 |
prefix_match_unit other than the block size, one group | ValueError | vllm/v1/core/kv_cache_utils.py:845-857 |
| A group size not divisible by the hash size | ValueError | vllm/v1/core/kv_cache_utils.py:892-898 |
| Hit boundaries do not match a compressed spec | ValueError | vllm/v1/core/kv_cache_utils.py:912-917 |
| A coarse view with a size that is not a multiple | AssertionError | vllm/v1/core/kv_cache_utils.py:1152 |
| Variable | Default | Read at | Effect |
|---|---|---|---|
PYTHONHASHSEED | unset | vllm/v1/core/kv_cache_utils.py:125, vllm/v1/core/kv_cache_utils.py:150 | seed of NONE_HASH; without it, xxhash gets a random seed and a WARNING |
VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES | 1 (vllm/envs.py:2022-2024) | vllm/v1/core/kv_cache_utils.py:83 | KV events carry 64-bit integers instead of digest bytes |
Related concepts#
- Prefix caching in the engine core: hashes, lookup, caching, eviction and KV events: who calls the hasher and the queue, and when.
- The hybrid KV cache: specs, groups and one manager per layer type: why several groups need the block-size helpers.
- Prefix caching: the idea: the hash chain and the extra keys, step by step.
- v1/core/block_pool.py: BlockPool and the prefix-cache map: the pool that owns the blocks and the queue.
- The KV cache manager: blocks, the block pool and memory sizing: the managers above the pool.
Check yourself#
- 1Why does vLLM implement its own linked list instead of usingeasy
collections.dequefor free blocks?Show answer
A prefix-cache hit must take a block out of the middle of the free queue. The linked list does this in O(1), a deque does not (
vllm/v1/core/kv_cache_utils.py:258-266,vllm/v1/core/kv_cache_utils.py:391-412). - 2How many bytes is one key of the prefix-cache map with the default hash algorithm, and what are its parts?medium
Show answer
36 bytes: the 32-byte SHA-256 digest of the block plus the KV cache group id as 4 big-endian bytes (
vllm/v1/core/kv_cache_utils.py:60-69,vllm/utils/hashing.py:40-41). - 3A request has 47 tokens and 2 hashes (block size 16). It gets one more token. What does the hasher return?medium
Show answer
One new hash. It starts at
2 × 16 = 32tokens;32 + 16 = 48 ≤ 48, so block 2 is full now (vllm/v1/core/kv_cache_utils.py:967-972,vllm/v1/core/kv_cache_utils.py:989-1008). The request appends it toblock_hashes(vllm/v1/request.py:285-288). - 4Look at the deep dive "Worked example: block sizes for two groups". Why is the hash of sliding-window block 1 the fine hash 3, not a new hash of tokens 32-63?hard
Show answer
Each fine hash chains over its whole prefix, so fine hash 3 (tokens 48-63) already names tokens 0-63. That is exactly the prefix of sliding-window block 1. The view returns
block_hashes[(idx + 1) × scale_factor − 1]=block_hashes[3](vllm/v1/core/kv_cache_utils.py:1178-1181).