vLLM Explained

v1/core/kv_cache_utils.py: block hashes, KVCacheBlock and the free queue

AdvancedCode mapabout 9 min read + 3 min deep dives

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.

Filevllm/v1/core/kv_cache_utils.py, 1,217 lines of Python. Four classes and 28 top-level functions.
Main classesKVCacheBlock (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 inThe 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 byEngineCore at start-up, Request, BlockPool, the single-type managers, KVCacheManager, the scheduler, and many KV connectors (29 files in vllm/ import it).
CallsThe 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 fileThe 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#

Figure: the life of one block. This file supplies the block record, the free queue and the hashes; block_pool.py runs the cycle (from Prefix caching in the engine core: hashes, lookup, caching, eviction and KV events).
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

The file in order. Line ranges are at the pinned commit.

LinesNameWhat it does
vllm/v1/core/kv_cache_utils.py:44-85BlockHash, BlockHashWithGroupId, ExternalBlockHash, pack/unpack helpersthe block hash types; group id as 4 big-endian bytes
vllm/v1/core/kv_cache_utils.py:90-158NONE_HASH, resolve_none_hash_seed, get_none_hash_seed, init_none_hashthe parent hash of block 0 (NONE_HASH)
vllm/v1/core/kv_cache_utils.py:161-224KVCacheBlockthe record of one block (KVCacheBlock)
vllm/v1/core/kv_cache_utils.py:227-250null_head_len, trim_null_headcount or drop the leading null blocks of a block table
vllm/v1/core/kv_cache_utils.py:253-255KVCacheBlockCopya (source, destination) pair for copy-on-write
vllm/v1/core/kv_cache_utils.py:258-519FreeKVCacheBlockQueuethe 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_keysthe extra keys of one block
vllm/v1/core/kv_cache_utils.py:678-704to_event_extra_keysextra keys without tags, for KV events
vllm/v1/core/kv_cache_utils.py:707-737hash_block_tokensone link of the hash chain
vllm/v1/core/kv_cache_utils.py:740-813resolve_dcp_kv_block_size, resolve_dcp_kv_cache_spec, partial_hash_hits_enabled, eagle_proof_marginblock sizes under decode context parallelism; fine-grained hits; EAGLE margin
vllm/v1/core/kv_cache_utils.py:816-950resolve_kv_cache_block_sizes, resolve_cache_hit_alignment_tokensscheduler block size, hash block size and hit alignment for one or several groups
vllm/v1/core/kv_cache_utils.py:953-1012get_request_block_hasherthe closure that hashes the new full blocks of a request
vllm/v1/core/kv_cache_utils.py:1015-1108get_max_concurrency_for_kv_cache_config, generate_scheduler_kv_cache_config, get_kv_cache_capacity, update_kv_cache_capacitythe scheduler's copy of the KV cache config, and the capacity log line
vllm/v1/core/kv_cache_utils.py:1111-1184BlockHashListWithBlockSize, BlockHashLista lazy view of fine hashes at a larger block size
vllm/v1/core/kv_cache_utils.py:1187-1217resolve_block_hashespicks 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
  1. Line 161. slots=True: no __dict__ per block. A large GPU has tens of thousands of these objects.
  2. Lines 166-168. The id indexes the KV tensors of every worker. ref_cnt counts the requests that hold the block.
  3. 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).
  4. Lines 175-178. The hash key, set only when the block is cached. _block_hash_num_tokens is the prefix length that the key covers. It can end inside a block for fine-grained (partial) entries.
  5. Lines 182-183. The two links of the free queue. Only FreeKVCacheBlockQueue changes them.
  6. Line 186. is_null marks block 0, the null block.
  7. Lines 201-203. A second hash on the same block is a bug, so the method asserts. BlockPool calls reset_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
  1. Lines 285-289. At start-up the queue links the blocks in id order: block 0, 1, 2, ...
  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.
  3. Lines 400-403. remove() on a block that is not in the queue raises RuntimeError: a caller bug.
  4. 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
  1. 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_unit raises ValueError when hashing is active.
  2. Lines 860-863. Several groups: the scheduler works in units of the least common multiple of the group block sizes.
  3. 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 dive

Data layout of the hash key

ValuePython typeBytes (sha256)Where
BlockHashNewType("BlockHash", bytes)32 (16 for xxhash)vllm/v1/core/kv_cache_utils.py:47
BlockHashWithGroupIdbytes: digest + group_id.to_bytes(4, "big")36vllm/v1/core/kv_cache_utils.py:60-69
get_block_hash(key)key[:-4]32vllm/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 int8 as an integervllm/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 dive

Worked 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.)

  1. FreeKVCacheBlockQueue([b0..b5]): head ⇄ 0 ⇄ 1 ⇄ 2 ⇄ 3 ⇄ 4 ⇄ 5 ⇄ tail, num_free_blocks = 6.
  2. The pool pops block 0 as the null block: head ⇄ 1 ⇄ 2 ⇄ 3 ⇄ 4 ⇄ 5 ⇄ tail, 5 free.
  3. 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 are None.
  4. Blocks 1 and 2 are full and get hashes; block 3 has 8 tokens.
  5. The request ends. The pool frees [3, 2, 1]: prepend_n([3]), then append_n([2, 1]). Queue: 3 ⇄ 4 ⇄ 5 ⇄ 2 ⇄ 1.
  6. A new request hits h0: remove(block 1) links 2 to the tail. Queue: 3 ⇄ 4 ⇄ 5 ⇄ 2.
Deep dive

Worked 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.)

  1. group_block_sizes = [16, 32], so scheduler_block_size = lcm(16, 32) = 32.
  2. Both groups are prefix-cacheable: hash_block_size = gcd(16, 32) = 16. 16 and 32 both divide by 16, so no error.
  3. 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 hash 2i + 1.
  4. 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 dive

Error paths and environment variables

SituationResultWhere
A second hash on a blockAssertionErrorvllm/v1/core/kv_cache_utils.py:201-203
popleft() on an empty queueValueError "No free blocks available"vllm/v1/core/kv_cache_utils.py:320-325
remove() or popleft() on a block without linksRuntimeErrorvllm/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 groupValueErrorvllm/v1/core/kv_cache_utils.py:845-857
A group size not divisible by the hash sizeValueErrorvllm/v1/core/kv_cache_utils.py:892-898
Hit boundaries do not match a compressed specValueErrorvllm/v1/core/kv_cache_utils.py:912-917
A coarse view with a size that is not a multipleAssertionErrorvllm/v1/core/kv_cache_utils.py:1152
VariableDefaultRead atEffect
PYTHONHASHSEEDunsetvllm/v1/core/kv_cache_utils.py:125, vllm/v1/core/kv_cache_utils.py:150seed of NONE_HASH; without it, xxhash gets a random seed and a WARNING
VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES1 (vllm/envs.py:2022-2024)vllm/v1/core/kv_cache_utils.py:83KV events carry 64-bit integers instead of digest bytes

Check yourself#

  1. 1
    Why does vLLM implement its own linked list instead of using collections.deque for free blocks?
    easy
    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).

  2. 2
    How 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).

  3. 3
    A 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 = 32 tokens; 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 to block_hashes (vllm/v1/request.py:285-288).

  4. 4
    Look 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).