v1/core/block_pool.py: BlockPool and the prefix-cache map
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.
| File | vllm/v1/core/block_pool.py, 905 lines of Python. Two classes. |
|---|---|
| Main classes | BlockPool (vllm/v1/core/block_pool.py:136) and BlockHashToBlockMap (vllm/v1/core/block_pool.py:35). |
| Runs in | The 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 by | The SingleTypeKVCacheManagers (single_type_kv_cache_manager.py), the coordinator, KVCacheManager, and the scheduler for deferred frees. |
| Calls | FreeKVCacheBlockQueue 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 file | The 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#
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#
- The coordinator builds one pool with
kv_cache_config.num_blocksblocks (vllm/v1/core/kv_cache_coordinator.py:119-125). - Single-type managers call
get_new_blocks,touch,free_blocks,cache_full_blocksandget_cached_block(vllm/v1/core/single_type_kv_cache_manager.py:303-306,vllm/v1/core/single_type_kv_cache_manager.py:406,vllm/v1/core/single_type_kv_cache_manager.py:531-539,vllm/v1/core/single_type_kv_cache_manager.py:597-598,vllm/v1/core/single_type_kv_cache_manager.py:794). KVCacheManagerreadsget_usageandtake_events, and callsevict_blocks(vllm/v1/core/kv_cache_manager.py:234,vllm/v1/core/kv_cache_manager.py:672,vllm/v1/core/kv_cache_manager.py:703).- The scheduler frees deferred blocks directly (
vllm/v1/core/sched/scheduler.py:2884,vllm/v1/core/sched/scheduler.py:2901).
What it calls#
FreeKVCacheBlockQueue:popleft,popleft_n,remove,prepend_n,append_n(vllm/v1/core/kv_cache_utils.py:258-484).- Hash helpers:
make_block_hash_with_group_id,resolve_block_hashes,generate_block_hash_extra_keys(vllm/v1/core/block_pool.py:16-29). - The event types
BlockStored,BlockRemoved,AllBlocksCleared(vllm/v1/core/block_pool.py:6-12).
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
BlockHashToBlockMap | vllm/v1/core/block_pool.py:35-133 | hash key → one block, or a dict of blocks with the same hash |
BlockPool.__init__ | vllm/v1/core/block_pool.py:157-196 | all blocks, the free queue, the maps, the null block |
get_cached_block | vllm/v1/core/block_pool.py:198-224 | a cached block per group for one hash, or None |
cache_full_blocks | vllm/v1/core/block_pool.py:226-344 | sets the hash of newly full blocks; emits BlockStored |
emit_cached_block_events | vllm/v1/core/block_pool.py:376-447 | events for reused blocks (report mode "full") |
cache_partial_block | vllm/v1/core/block_pool.py:449-563 | a hit entry inside a larger block (fine-grained hashing) |
move_block_hashes | vllm/v1/core/block_pool.py:648-664 | moves cache entries to a copy-on-write block |
get_new_blocks | vllm/v1/core/block_pool.py:666-700 | pops blocks from the front; evicts their old hash; ref_cnt = 1 |
unpin_blocks | vllm/v1/core/block_pool.py:708-727 | releases blocks that stay readable until reused (connectors) |
touch | vllm/v1/core/block_pool.py:752-770 | ref_cnt += 1; out of the free queue if it was free |
free_blocks | vllm/v1/core/block_pool.py:776-809 | ref_cnt -= 1; at 0: front (no hash) or end (hash) |
evict_blocks | vllm/v1/core/block_pool.py:811-829 | removes hashes by block id (KV connector) |
reset_prefix_cache | vllm/v1/core/block_pool.py:831-870 | drops every hash, only when no block is in use |
get_num_free_blocks, get_usage | vllm/v1/core/block_pool.py:872-892 | free count; KV cache usage |
take_events | vllm/v1/core/block_pool.py:894-905 | drains 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- Lines 172-174. One
KVCacheBlockper 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). - Line 178. All blocks start in the free queue, in id order.
- Lines 181-182. The prefix-cache map, and a second map for blocks with more than one hash entry.
- Lines 187-188. The first block (id 0) leaves the queue and becomes the null block. Its
ref_cntis not kept, sofree_blocksskips 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- Lines 678-679. A request for more blocks than are free raises
ValueError. Callers count first, so this means a bug. - Line 681.
popleft_n: the front of the queue is the eviction end. - Lines 687-691. With prefix caching, a popped block may still hold a hash;
_maybe_evict_cached_blockremoves it from the map and emitsBlockRemoved(vllm/v1/core/block_pool.py:729-750). This is the only eviction path in normal serving. Thenref_cntgoes 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)- Lines 790-794. The null block is skipped; a block of another pool goes to its own pool.
- 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. - Line 805.
prepend_n: unhashed blocks at the front, reused next. The comment names GPU locality as a reason. - 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 diveData layout: KVCacheBlock and the two maps
| Field / structure | Type | Meaning |
|---|---|---|
KVCacheBlock.block_id | int, 0 .. num_gpu_blocks − 1 | index of the block in the KV tensors of every worker (vllm/v1/core/kv_cache_utils.py:165-166) |
ref_cnt | int | requests that hold the block; 0 = in the free queue (except the null block) |
_block_hash | BlockHashWithGroupId | None | bytes 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_block | KVCacheBlock | None | links of the free queue; None when not in the queue |
is_null | bool | True only for block 0 |
cached_block_hash_to_block | dict[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_block | dict[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 diveWorked 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.)
- 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_blockssets their hashes. - 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. - 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. - 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. - 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 diveKV 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 diveError paths
| Situation | Result | Where |
|---|---|---|
num_gpu_blocks not a positive int | AssertionError | vllm/v1/core/block_pool.py:166 |
get_new_blocks beyond the free count | ValueError | vllm/v1/core/block_pool.py:678-679 |
popleft on an empty queue | ValueError "No free blocks available" | vllm/v1/core/kv_cache_utils.py:320-325 |
evict_blocks with an id out of range | AssertionError that blames the KV connector | vllm/v1/core/block_pool.py:823-827 |
reset_prefix_cache with blocks in use | WARNING log, returns False | vllm/v1/core/block_pool.py:841-848 |
| A wrong type in the hash map | AssertionError | vllm/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).
Related concepts#
- The KV cache manager: blocks, the block pool and memory sizing: how the pool fits under the manager, memory sizing and usage.
- Prefix caching: the idea and Prefix caching in the engine core: hashes, lookup, caching, eviction and KV events: what the hash map is for.
- v1/core/kv_cache_utils.py: block hashes, KVCacheBlock and the free queue:
KVCacheBlock, the free queue and the hash functions. - v1/core/kv_cache_manager.py: KVCacheManager and KVCacheBlocks: the API above the pool.
Check yourself#
- 1Where doeseasy
get_new_blockstake blocks from, and what happens to an old hash?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). - 2Why 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). - 3In 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, soappend_nadded 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.