vLLM Explained

v1/core/block_pool.py: BlockPool and the prefix-cache map

AdvancedCode mapabout 7 min read + 3 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

Purpose#

vllm/v1/core/block_pool.py owns all KVCacheBlock objects of one engine core: which are free, which are in use, and which full blocks can serve a prefix-cache hit.

Filevllm/v1/core/block_pool.py, 905 lines of Python. Two classes.
Main classesBlockPool (vllm/v1/core/block_pool.py:136) and BlockHashToBlockMap (vllm/v1/core/block_pool.py:35).
Runs inThe EngineCore process, main thread. One pool per KV cache coordinator, so one per scheduler, shared by all KV cache groups. The only exception is the host group of HiSparse, which gets a private host pool (vllm/v1/core/single_type_kv_cache_manager.py:2231-2247). CPU only.
Called byThe SingleTypeKVCacheManagers (single_type_kv_cache_manager.py), the coordinator, KVCacheManager, and the scheduler for deferred frees.
CallsFreeKVCacheBlockQueue and the hash helpers of kv_cache_utils.py; the KV event types of vllm/distributed/kv_events.py; an optional metrics collector.
Not in this fileThe block class and the free queue (kv_cache_utils.py), the hit search (single-type managers), the decision when to cache (managers), the GPU memory (workers).

Where it sits#

Figure: the order of the free queue (1) and the life of one block (2).
Open full size ↗

Read panel 1 from left to right: the pool reuses the front first. Panel 2 shows that a free block can still be a prefix-cache hit until the pool pops it again.

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
BlockHashToBlockMapvllm/v1/core/block_pool.py:35-133hash key → one block, or a dict of blocks with the same hash
BlockPool.__init__vllm/v1/core/block_pool.py:157-196all blocks, the free queue, the maps, the null block
get_cached_blockvllm/v1/core/block_pool.py:198-224a cached block per group for one hash, or None
cache_full_blocksvllm/v1/core/block_pool.py:226-344sets the hash of newly full blocks; emits BlockStored
emit_cached_block_eventsvllm/v1/core/block_pool.py:376-447events for reused blocks (report mode "full")
cache_partial_blockvllm/v1/core/block_pool.py:449-563a hit entry inside a larger block (fine-grained hashing)
move_block_hashesvllm/v1/core/block_pool.py:648-664moves cache entries to a copy-on-write block
get_new_blocksvllm/v1/core/block_pool.py:666-700pops blocks from the front; evicts their old hash; ref_cnt = 1
unpin_blocksvllm/v1/core/block_pool.py:708-727releases blocks that stay readable until reused (connectors)
touchvllm/v1/core/block_pool.py:752-770ref_cnt += 1; out of the free queue if it was free
free_blocksvllm/v1/core/block_pool.py:776-809ref_cnt -= 1; at 0: front (no hash) or end (hash)
evict_blocksvllm/v1/core/block_pool.py:811-829removes hashes by block id (KV connector)
reset_prefix_cachevllm/v1/core/block_pool.py:831-870drops every hash, only when no block is in use
get_num_free_blocks, get_usagevllm/v1/core/block_pool.py:872-892free count; KV cache usage
take_eventsvllm/v1/core/block_pool.py:894-905drains the KV event queue

Walk through the main methods#

__init__: all blocks at once, and the null block#

        assert isinstance(num_gpu_blocks, int) and num_gpu_blocks > 0
        self.num_gpu_blocks = num_gpu_blocks
        self.medium = medium
        self.enable_caching = enable_caching
        self.hash_block_size = hash_block_size
        # All kv-cache blocks.
        self.blocks: list[KVCacheBlock] = [
            KVCacheBlock(idx, pool=self) for idx in range(num_gpu_blocks)
        ]
        # Free block queue that constructs and manipulates a doubly linked
        # list of free blocks (including eviction candidates when caching is
        # enabled).
        self.free_block_queue = FreeKVCacheBlockQueue(self.blocks)

        # Cache for block lookup
        self.cached_block_hash_to_block: BlockHashToBlockMap = BlockHashToBlockMap()
        self.cached_block_hashes_by_block: dict[int, set[BlockHashWithGroupId]] = {}

        # To represent a placeholder block with block_id=0.
        # The ref_cnt of null_block is not maintained, needs special care to
        # avoid freeing it.
        self.null_block = self.free_block_queue.popleft()
        self.null_block.is_null = True
  1. Lines 172-174. One KVCacheBlock per id, all made at start-up. A block remembers its pool, because a deferred free can hold blocks of two pools (the GPU pool and a HiSparse host pool) (vllm/v1/core/kv_cache_utils.py:169-172).
  2. Line 178. All blocks start in the free queue, in id order.
  3. Lines 181-182. The prefix-cache map, and a second map for blocks with more than one hash entry.
  4. Lines 187-188. The first block (id 0) leaves the queue and becomes the null block. Its ref_cnt is not kept, so free_blocks skips it (vllm/v1/core/block_pool.py:790-791).

get_new_blocks: take from the front#

    def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
        """Get new blocks from the free block pool.

        Note that we do not check block cache in this function.

        Args:
            num_blocks: The number of blocks to allocate.

        Returns:
            A list of new block.

        """
        if num_blocks > self.get_num_free_blocks():
            raise ValueError(f"Cannot get {num_blocks} free blocks from the pool")

        ret: list[KVCacheBlock] = self.free_block_queue.popleft_n(num_blocks)

        if self._reuse_watchers:
            self._notify_reuse(ret)

        # In order to only iterate the list once, we duplicated code a bit
        if self.enable_caching:
            for block in ret:
                self._maybe_evict_cached_block(block)
                assert block.ref_cnt == 0
                block.ref_cnt += 1
                if self.metrics_collector:
                    self.metrics_collector.on_block_allocated(block)
        else:
            for block in ret:
                assert block.ref_cnt == 0
                block.ref_cnt += 1
                if self.metrics_collector:
                    self.metrics_collector.on_block_allocated(block)
        return ret
  1. Lines 678-679. A request for more blocks than are free raises ValueError. Callers count first, so this means a bug.
  2. Line 681. popleft_n: the front of the queue is the eviction end.
  3. Lines 687-691. With prefix caching, a popped block may still hold a hash; _maybe_evict_cached_block removes it from the map and emits BlockRemoved (vllm/v1/core/block_pool.py:729-750). This is the only eviction path in normal serving. Then ref_cnt goes from 0 to 1.

free_blocks: back in eviction order#

    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.

        Args:
            ordered_blocks: A list of blocks to free ordered by their eviction
                priority.

        """
        # Identify blocks with hash (LRU cache) and without it (never match APC)
        blocks_to_evict_last = []
        blocks_to_evict_first = []
        other_pools: dict[BlockPool, list[KVCacheBlock]] = {}
        for block in ordered_blocks:
            if block.is_null:  # never pooled; its ref_cnt is not maintained
                continue
            if block.pool is not None and block.pool is not self:
                other_pools.setdefault(block.pool, []).append(block)
                continue
            block.ref_cnt -= 1
            if block.ref_cnt == 0:
                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)
        for pool, blocks in other_pools.items():
            pool.free_blocks(blocks)
  1. Lines 790-794. The null block is skipped; a block of another pool goes to its own pool.
  2. Lines 795-802. ref_cnt -= 1. At 0, a block without a hash (or any block with prefix caching off) goes to the "evict first" list; a hashed block to the "evict last" list.
  3. Line 805. prepend_n: unhashed blocks at the front, reused next. The comment names GPU locality as a reason.
  4. Line 807. append_n: cached blocks at the end, in the order given. Callers pass the blocks of a request in reverse, so the tail of a prefix is evicted before its head.

A block that another request still uses keeps ref_cnt > 0 and stays out of the queue.

touch and cache_full_blocks#

    def touch(self, blocks: Sequence[KVCacheBlock]) -> None:
        """Touch a block increases its reference count by 1, and may remove
        the block from the free queue. This is used when a block is hit by
        another request with the same prefix.

        Args:
            blocks: A list of blocks to touch.

        """
        for block in blocks:
            if block.is_null:
                continue
            # ref_cnt=0 means this block is in the free list (i.e. eviction
            # candidate), so remove it.
            if block.ref_cnt == 0:
                self.free_block_queue.remove(block)
            block.ref_cnt += 1
            if self.metrics_collector:
                self.metrics_collector.on_block_accessed(block)

touch is how a hit takes a block: it removes the block from the middle of the free queue when the block was free (O(1) in the linked list), and adds a reference. cache_full_blocks gives each newly full block its hash key (block hash + group id) and inserts it in the map (vllm/v1/core/block_pool.py:261-301). It skips null blocks and blocks that a mask leaves out, for example sliding-window blocks that can never serve a hit (vllm/v1/core/block_pool.py:273-278).

get_usage: the KV cache usage metric#

    def get_usage(self) -> float:
        """Get the KV cache usage.

        Returns:
            The KV cache usage (between 0.0 and 1.0).

        """
        # Subtract 1 to account for null block.
        total_gpu_blocks = self.num_gpu_blocks - 1
        if not total_gpu_blocks:
            return 0
        return 1.0 - (self.get_num_free_blocks() / total_gpu_blocks)

The null block is left out of the total. Free blocks with a cached prefix count as free. This value becomes vllm:kv_cache_usage_perc (The KV cache manager: blocks, the block pool and memory sizing, Step 8).

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: KVCacheBlock and the two maps

Field / structureTypeMeaning
KVCacheBlock.block_idint, 0 .. num_gpu_blocks − 1index of the block in the KV tensors of every worker (vllm/v1/core/kv_cache_utils.py:165-166)
ref_cntintrequests that hold the block; 0 = in the free queue (except the null block)
_block_hashBlockHashWithGroupId | Nonebytes of the block hash plus the group id; set only when full and cached (vllm/v1/core/kv_cache_utils.py:173-178)
prev_free_block, next_free_blockKVCacheBlock | Nonelinks of the free queue; None when not in the queue
is_nullboolTrue only for block 0
cached_block_hash_to_blockdict[key, KVCacheBlock | dict[int, KVCacheBlock]]the prefix cache; a dict of blocks only when two blocks have the same hash (vllm/v1/core/block_pool.py:57-60)
cached_block_hashes_by_blockdict[int, set[key]]extra keys of a block (partial entries), so eviction removes them all

The class uses @dataclass(slots=True), so each block has no __dict__ (vllm/v1/core/kv_cache_utils.py:161). With 27,955 blocks (an 8B model on an 80 GiB GPU, The KV cache manager: blocks, the block pool and memory sizing), the pool holds 27,955 such objects plus 2 fake queue ends.

Why can two blocks have the same hash? The map does not deduplicate: when a block becomes full, the pool does not look for an identical cached block, so that block ids in the block tables never change (vllm/v1/core/block_pool.py:48-52). Two requests that computed the same block at the same time both keep their own copy.

Deep dive

Worked example: eight blocks, two requests

A pool of 9 blocks (block 0 is null), block size 4, prefix caching on. Free queue at start: 1 2 3 4 5 6 7 8. (Derived from the methods above.)

  1. R1 (10 tokens) gets 3 blocks: get_new_blocks(3) → 1, 2, 3. Queue: 4 5 6 7 8. Blocks 1 and 2 are full: cache_full_blocks sets their hashes.
  2. R2 has the same first 8 tokens and 12 tokens in total. Hit: blocks 1, 2 → touch: ref_cnt 2 each (they were not in the queue). New: 1 block → 4. Queue: 5 6 7 8.
  3. R1 ends after one output token: free_blocks([3, 2, 1]). Block 3 (no hash): ref_cnt 0, to the front. Blocks 2, 1: ref_cnt 1, stay out. Queue: 3 5 6 7 8.
  4. R2 ends after one output token: free_blocks([4, 2, 1]). Block 4 is full (tokens 8-11) and hashed. All three reach 0 and have a hash: append in order 4, 2, 1. Queue: 3 5 6 7 8 4 2 1.
  5. R3 needs 6 blocks: it pops 3, 5, 6, 7, 8, 4. Block 4 loses its hash (eviction). Blocks 2 and 1 survive at the end, so the first 8 tokens are still a possible hit.
Deep dive

KV cache events

With --kv-events-config and enable_kv_cache_events, the pool queues an event for each change of the prefix cache: BlockStored when blocks get hashes (vllm/v1/core/block_pool.py:303-344), BlockRemoved when a hash goes (vllm/v1/core/block_pool.py:611-624), and AllBlocksCleared on a reset (vllm/v1/core/block_pool.py:867-868). The scheduler drains them in update_from_output and publishes one batch per step (vllm/v1/core/sched/scheduler.py:2469-2484). External routers use these events to know which prefixes an engine has cached.

Deep dive

Error paths

SituationResultWhere
num_gpu_blocks not a positive intAssertionErrorvllm/v1/core/block_pool.py:166
get_new_blocks beyond the free countValueErrorvllm/v1/core/block_pool.py:678-679
popleft on an empty queueValueError "No free blocks available"vllm/v1/core/kv_cache_utils.py:320-325
evict_blocks with an id out of rangeAssertionError that blames the KV connectorvllm/v1/core/block_pool.py:823-827
reset_prefix_cache with blocks in useWARNING log, returns Falsevllm/v1/core/block_pool.py:841-848
A wrong type in the hash mapAssertionErrorvllm/v1/core/block_pool.py:132-133

The file reads no environment variable. Its only settings are the constructor arguments: num_gpu_blocks, enable_caching, hash_block_size, enable_kv_cache_events (vllm/v1/core/block_pool.py:157-165).

Check yourself#

  1. 1
    Where does get_new_blocks take blocks from, and what happens to an old hash?
    easy
    Show answer

    From the front of the free queue with popleft_n (vllm/v1/core/block_pool.py:681). With prefix caching, it removes the old hash of each block from the map: this is the eviction (vllm/v1/core/block_pool.py:687-691, vllm/v1/core/block_pool.py:729-750).

  2. 2
    Why does a freed block without a hash go to the front of the queue, and a block with a hash to the end?
    medium
    Show answer

    A block without a hash can never be a prefix-cache hit, so reusing it first costs nothing. A hashed block may serve a later hit, so it waits as long as possible (LRU) (vllm/v1/core/block_pool.py:797-807).

  3. 3
    In the worked example of the deep dive, why do blocks 2 and 1 survive R3 while block 4 does not, although all three were freed at the same time?
    hard
    Show answer

    R2 freed its blocks in reverse order, [4, 2, 1] (vllm/v1/core/single_type_kv_cache_manager.py:597-598). All three had a hash, so append_n added them to the end in that order (vllm/v1/core/block_pool.py:807). Block 4 is nearer the front, so R3 popped it first. The tail of a prefix goes before its head, which keeps the more shareable head longer.