vLLM Explained

The hybrid KV cache: specs, groups and one manager per layer type

AdvancedvLLM conceptsabout 16 min read + 5 min deep dives

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

In one sentence

vLLM groups the layers with the same KV cache spec, gives each group its own block table, and shares one block pool between groups.

The idea#

Many new models mix layer types. Some layers use full attention and read every earlier token. Other layers use a sliding window and read only the last few tokens, or chunked local attention, or a Mamba layer with a fixed-size state. The design document lists examples: gpt-oss, Gemma 2 and 3, Llama 4, Jamba and others (docs/design/hybrid_kv_cache_manager.md:6-21). If every layer kept the KV of every token, the sliding-window layers would hold blocks that they never read again.

The hybrid KV cache manager gives each layer type its own rules, on top of the KV cache manager:

  1. Each layer describes its cache with a KV cache spec: full attention, sliding window, chunked local, MLA, Mamba and more.
  2. Layers with the same spec form a KV cache group. A request has one block table per group.
  3. Each group gets a single-type manager that knows the rules of its spec. The SlidingWindowManager frees blocks behind the window.
  4. All groups take block ids from one block pool. A block id belongs to one group at a time, and it means the same bytes in every group. So every group must use the same page size in bytes (page size unification).

Prefix caching still works: the coordinator finds the longest prefix that every group can reuse.

An everyday comparison. Think of a hotel with one stock of identical rooms and two kinds of guests. A long-stay guest keeps every room that he ever booked. A traveller keeps only the last two rooms and checks out of older rooms as he moves on. Both kinds of guests take rooms from the same stock, so all rooms must have the same size. The comparison stops at the room number. In vLLM, one block id stands for one page in each layer of its group, not for one place.

The picture#

Figure: the two groups of a sliding-window + full-attention model (1), the two block tables of one request (2), and why one block id must mean the same number of bytes in every group (3). Model numbers are from the public gpt-oss-20b config; block ids are illustrative.
Open full size ↗

Read panel 2 from left to right: the sliding table keeps its length, but its first places hold the null block. Panel 3 shows the memory side: the two groups use the same bytes, and only the owner of a block id writes into it.

In the code#

Step 1: each layer reports a KV cache spec#

After loading the model, each worker asks every attention layer for its spec. A decoder layer with a sliding window returns a SlidingWindowSpec, any other decoder layer a FullAttentionSpec:

            return SlidingWindowSpec(
                block_size=sw_block_size,
                num_kv_heads=self.num_kv_heads,
                head_size=self.head_size,
                head_size_v=self.head_size_v,
                dtype=self.kv_cache_torch_dtype,
                kv_quant_mode=quant_mode,
                sliding_window=self.sliding_window,
                page_size_padded=shared_page,
            )
        else:
            return FullAttentionSpec(
                block_size=block_size,
                num_kv_heads=self.num_kv_heads,
                head_size=self.head_size,
                head_size_v=self.head_size_v,
                dtype=self.kv_cache_torch_dtype,
                kv_quant_mode=quant_mode,
            )

The spec classes are in vllm/v1/kv_cache_interface.py. The registry maps each class to a single-type manager (vllm/v1/core/single_type_kv_cache_manager.py:2645-2715):

SpecLayer typeManagerBlocks per request
FullAttentionSpec (vllm/v1/kv_cache_interface.py:603-633)full attentionFullAttentionManagerone per block_size tokens, all kept
MLAAttentionSpec (vllm/v1/kv_cache_interface.py:713-728)MLA (one latent vector, head_size_v = 0)FullAttentionManagerall kept
SlidingWindowSpec (vllm/v1/kv_cache_interface.py:885-949)sliding windowSlidingWindowManageronly the window, plus in-flight tokens
ChunkedLocalAttentionSpec (vllm/v1/kv_cache_interface.py:845-882)chunked local (Llama 4)ChunkedLocalAttentionManageronly the current chunk, plus in-flight tokens
MambaSpec (vllm/v1/kv_cache_interface.py:1078-1151)Mamba / linear attention stateMambaManager1 to a few state blocks
CrossAttentionSpec (vllm/v1/kv_cache_interface.py:1200-1208)encoder-decoder cross attentionCrossAttentionManagerencoder tokens

More specs exist for special models (R-SWA, sinks, circular buffers, HiSparse). A platform can register its own (vllm/v1/core/single_type_kv_cache_manager.py:2717-2719). The page size of an attention spec is num_heads × num_states × (head_size + head_size_v) × dtype bytes, where num_states is the block size for normal layers (vllm/v1/kv_cache_interface.py:570-586, vllm/v1/kv_cache_interface.py:253-260). The page size of a Mamba spec comes from its state shapes, not from the block size (vllm/v1/kv_cache_interface.py:1094-1114).

Step 2: from specs to groups#

The engine core turns the specs of all layers into groups in get_kv_cache_configs() (vllm/v1/engine/core.py:311). A model can bring its own grouping (SupportsKVCacheGrouping); otherwise the default builder calls get_kv_cache_groups() (vllm/v1/core/kv_cache_planning.py:1659-1670). It tries the simple cases first:

    if vllm_config.scheduler_config.disable_hybrid_kv_cache_manager:
        unify_hybrid_kv_cache_specs(kv_cache_spec)

    if is_kv_cache_type_attention_free(kv_cache_spec):
        # This returns an empty list to allow for the KVCacheManager to handle
        # attention free models.
        return []

    if hisparse_groups := get_hisparse_kv_cache_groups(vllm_config, kv_cache_spec):
        return hisparse_groups

    if is_kv_cache_spec_uniform(kv_cache_spec):
        # KV cache of all layers are the same, which is true for
        # most models. Allocate the same amount of memory for
        # each layer.
        return _get_kv_cache_groups_uniform_spec(kv_cache_spec)
    elif uniform_spec := UniformTypeKVCacheSpecs.from_specs(kv_cache_spec):
        # All layers need the same number of token slots (e.g., all layers are
        # full attention, or all layers are sliding window attention with the
        # same window size). Put all layers into one group.
        return _get_kv_cache_groups_uniform_type(uniform_spec)
    # Hidden-state layers use their own block table and must not be absorbed
    # into a compatible attention bucket.
    hidden_specs = {
        k: v for k, v in kv_cache_spec.items() if isinstance(v, HiddenStateCacheSpec)
    }
    filtered_spec = {
        k: v
        for k, v in kv_cache_spec.items()
        if not isinstance(v, HiddenStateCacheSpec)
    }

    if packed_groups := _get_packed_kv_cache_groups(vllm_config, filtered_spec):
        # Block-outermost blocks are strided by the widest group, so hidden
        # groups need no page alignment.
        packed_groups += [
            KVCacheGroupSpec([name], spec) for name, spec in hidden_specs.items()
        ]
        return packed_groups

    # Prefer preserving each layer's cache semantics. If physical pages cannot
    # be unified, try a supported allocation-only fallback before failing.
    try:
        filtered_spec = unify_kv_cache_spec_page_size(filtered_spec)
    except NotImplementedError:
        fallback_groups = _try_get_full_allocation_fallback_groups(kv_cache_spec)
        if fallback_groups is None:
            raise
        return fallback_groups
    groups = _get_kv_cache_groups_uniform_page_size(filtered_spec)
  1. Lines 1127-1128. With the hybrid manager off, sliding-window and chunked-local specs become full-attention specs first (Step 6).
  2. Lines 1138-1147. All layers have the same spec, or the same type: one group. A plain Llama ends here.
  3. Lines 1159-1165. Only with a block-outermost KV cache layout: a packed grouping that does not need equal page sizes (vllm/v1/core/kv_cache_planning.py:929-932).
  4. Lines 1170-1176. The normal hybrid path: make the page sizes equal (Step 3), then group the layers.

The grouping puts layers with the same spec into buckets. Then it cuts each bucket into groups of equal size, so every group has the same bytes per block:

    min_num_layers = min([len(layers) for layers in layer_buckets])
    group_size = min_num_layers
    max_num_layers = max([len(layers) for layers in layer_buckets])
    if max_num_layers < min_num_layers * 1.5:
        # If the number of layers is not much larger than the minimum number of
        # layers, use the maximum number of layers as the group size to avoid
        # too many padding layers. A typical example is gpt-oss-20b + eagle,
        # with 12 sw + 13 full. We pad it to (13 sw, 13 full) instead of
        # (12 sw, 24 full). 1.5 is a heuristic to avoid too many padding
        # layers while accommodating speculative decoding drafters that add
        # extra layers to one attention type.
        group_size = max_num_layers
    grouped_layers = []
    for layers in layer_buckets:
        num_padding_layers = group_size - len(layers) % group_size
        if num_padding_layers != group_size:
            logger.warning(
                "Add %d padding layers, may waste at most %.2f%% KV cache memory",  # noqa
                num_padding_layers,
                num_padding_layers / len(layers) * 100,
            )
        num_groups = cdiv(len(layers), group_size)
        # In PP case, say if we have
        # - stage 0: full.0, sw.0, sw.1
        # - stage 1: full.1, sw.2, sw.3
        # We should have 3 groups: (full.0, full.1), (sw.0, sw.2), (sw.1, sw.3)
        # It can't be (full.0, full.1), (sw.0, sw.1), (sw.2, sw.3) because
        # the 3 groups in stage 0 will be (full.0), (sw.0, sw.1), (empty group)
        # and it will be padded to (full.0, padding), (sw.0, sw.1),
        # (padding, padding) to ensure the number of layers in each group is
        # the same and will cause memory waste.
        # To avoid this, we assign layers[i::num_groups] to the i-th group
        # instead of layers[i * group_size: (i + 1) * group_size]
        for i in range(num_groups):
            grouped_layers.append(layers[i::num_groups])
    return create_kv_cache_group_specs(kv_cache_spec, grouped_layers)

The group size is the smallest bucket, or the largest bucket when the buckets are almost equal (lines 521-532). gpt-oss-20b has 12 sliding and 12 full layers, so it gets two groups of 12 (the comment at lines 526-531 names gpt-oss with EAGLE). A short group wastes memory, and the code logs a warning (lines 535-541). Line 555 takes every n-th layer, which keeps the groups equal with pipeline parallelism.

Each group becomes a KVCacheGroupSpec: the layer names and one merged spec (vllm/v1/kv_cache_interface.py:1484-1502, vllm/v1/core/kv_cache_planning.py:187-215).

Step 3: one page size for all groups#

All groups use the same memory. The KV cache tensors of different groups start at byte 0 of the same allocation, and the comment explains why that is safe (vllm/v1/kv_cache_interface.py:1458-1467):

    num_blocks = available_memory // bytes_per_block
    num_blocks = may_override_num_blocks(vllm_config, num_blocks)
    size = bytes_per_block * num_blocks

    # Groups alias from byte 0. Spec regions are laid out differently:
    #
    # block-outer (the same packing repeats for every block):
    # group 0: | blk 0 [ A | B  | pad ] | blk 1 [ A | B  | pad ] | ...
    # group 1: | blk 0 [  C  |    D   ] | blk 1 [  C  |    D   ] | ...
    #          |<--- bytes_per_block -->|
    #
    # layer-outer (only supported for uniform page sizes or single-group models):
    # group 0: | A [ blk 0 | blk 1 | ... ] | B [ blk 0 | blk 1 | ... ] |
    # group 1: | C [ blk 0 | blk 1 | ... ] | D [ blk 0 | blk 1 | ... ] |

So one block id must cover the same bytes in every group. unify_kv_cache_spec_page_size() makes the page sizes equal:

    page_sizes = {layer.page_size_bytes for layer in kv_cache_spec.values()}
    if len(page_sizes) <= 1:
        # All layers have the same page size, no need to unify.
        return kv_cache_spec

    max_page_size = max(page_sizes)
    new_kv_cache_spec = {}
    for layer_name, layer_spec in kv_cache_spec.items():
        if layer_spec.page_size_bytes == max_page_size:
            new_kv_cache_spec[layer_name] = layer_spec
        elif isinstance(layer_spec, MambaSpec):
            # MambaSpec's page size is determined by its state shapes and does
            # not scale with block_size, so pad the page instead. This is the
            # same padding mechanism the platform uses to align Mamba pages
            # with the main model's attention page size; it is needed here
            # when another layer (e.g. from a draft model) has a larger page
            # than the already-aligned Mamba page.
            new_spec: KVCacheSpec = replace(layer_spec, page_size_padded=max_page_size)
            assert new_spec.page_size_bytes == max_page_size
            new_kv_cache_spec[layer_name] = new_spec
        else:
            layer_page_size = layer_spec.page_size_bytes
            if max_page_size % layer_page_size == 0:
                ratio = max_page_size // layer_page_size
                new_block_size = layer_spec.block_size * ratio
                new_spec = replace(layer_spec, block_size=new_block_size)
            elif isinstance(layer_spec, AttentionSpec) and not isinstance(
                layer_spec, MLAAttentionSpec
            ):
                new_spec = replace(layer_spec, page_size_padded=max_page_size)
            else:
                raise NotImplementedError(
                    f"Layer {layer_name}: page size is not divisible by the "
                    "maximum page size and cannot be padded. Padding is only "
                    "supported for non-MLA attention layers."
                )
            assert new_spec.page_size_bytes == max_page_size
            new_kv_cache_spec[layer_name] = new_spec
    return new_kv_cache_spec
  1. Line 375. The target is the largest page.
  2. Lines 380-389. A Mamba page does not grow with the block size, so it is padded.
  3. Lines 392-395. If the largest page is a multiple of this page, the block size of this layer grows by that factor. Its group then has a larger block size than the others.
  4. Lines 396-399. Otherwise a non-MLA attention page is padded.
  5. Lines 400-405. MLA cannot be padded: NotImplementedError, and get_kv_cache_groups() tries a full-allocation fallback.

For attention + Mamba models, the platform does most of this work before the model is built. _align_hybrid_block_size() raises the attention block size until one attention page is at least one Mamba state. Then it pads the Mamba page to the attention page:

        # Use minimum block size that satisfies both backend alignment and
        # mamba page size compatibility
        attn_block_size = kernel_block_alignment_size * cdiv(
            mamba_page_size,
            kernel_block_alignment_size * attn_page_size_1_token,
        )
        attn_block_size = backend_block_alignment * cdiv(
            attn_block_size, backend_block_alignment
        )

        if cache_config.block_size < attn_block_size:
            cache_config.block_size = attn_block_size
            logger.info(
                "Setting attention block size to %d tokens "
                "to ensure that attention page size is >= mamba page size.",
                attn_block_size,
            )

        if cache_config.mamba_cache_mode == "align":
            cache_config.mamba_block_size = cache_config.block_size

        # Pad mamba page size to exactly match attention page size
        attn_page_size = cache_config.block_size * attn_page_size_1_token
        assert attn_page_size >= mamba_page_size

        if attn_page_size == mamba_page_size:
            return

        if (
            cache_config.mamba_page_size_padded is None
            or cache_config.mamba_page_size_padded != attn_page_size
        ):
            cache_config.mamba_page_size_padded = attn_page_size

When groups end up with different block sizes, the scheduler works in units of their least common multiple. Prefix hashes use their greatest common divisor, or --prefix-match-unit (vllm/v1/core/kv_cache_utils.py:860-891). BlockHashListWithBlockSize then reads the hashes of a larger block from the finer list (vllm/v1/core/kv_cache_utils.py:1111-1181).

Step 4: the coordinator and the single-type managers#

With more than one group and prefix caching on, the manager uses the HybridKVCacheCoordinator (vllm/v1/core/kv_cache_coordinator.py:1009-1038). Every coordinator makes one block pool and one manager per group (vllm/v1/core/kv_cache_coordinator.py:119-125, vllm/v1/core/kv_cache_coordinator.py:157-172). Each allocation call loops over the managers (vllm/v1/core/kv_cache_coordinator.py:335-344). The managers differ in three methods: how many tokens the attention skips, how a prefix hit looks, and which blocks get a hash.

FullAttentionManager. It skips nothing and keeps every block until the request ends (vllm/v1/core/single_type_kv_cache_manager.py:721-732).

SlidingWindowManager. The next token reads only the last sliding_window tokens, so everything before them is skipped:

        return max(
            0,
            num_computed_tokens - self.sliding_window + 1 - self.extra_retained_tokens,
        )

At the start of every allocate_slots(), the manager frees the skipped blocks and puts the null block in their place. It counts only processed tokens, because a step in flight can still read the old blocks (vllm/v1/core/kv_cache_manager.py:546-559):

        num_skipped_tokens = self.get_num_skipped_tokens(processed_computed_tokens)
        if num_skipped_tokens <= 0:
            # This indicates that ALL tokens are inside attention window.
            # Thus we do not need to free any blocks outside attention window.
            # A typical case is full attention that we never free any token
            # before the request is finished.
            return
        blocks = self.req_to_blocks[request_id]
        num_skipped_blocks = num_skipped_tokens // self.block_size
        # `num_skipped_tokens` may include tokens that haven't been allocated yet
        # (e.g., when the attention window moves into the external computed tokens
        # range), so we must cap to the number of blocks that currently exist for
        # this request.
        num_skipped_blocks = min(num_skipped_blocks, len(blocks))
        self._remove_blocks_in_range(request_id, 0, num_skipped_blocks)
        blocks = self.req_to_blocks[request_id]
        last_block = min(last_block, len(blocks))

        freed: list[KVCacheBlock] = []
        for i in range(last_block - 1, first_block - 1, -1):
            if blocks[i] == self._null_block:
                break
            freed.append(blocks[i])
            blocks[i] = self._null_block
        if freed:
            self.block_pool.free_blocks(freed)

The table keeps its length, so position i is still block i of the sequence. The worker sees the null block (id 0) at the freed places. Admission uses a cap per request, because the group frees blocks while the request runs (vllm/v1/kv_cache_interface.py:922-929, vllm/v1/core/single_type_kv_cache_manager.py:214-227).

Figure: the sliding-window block table over five steps (1), and what happens to the freed blocks (2). Illustrative numbers; the formulas are from the code.
Open full size ↗

ChunkedLocalAttentionManager. Chunked local attention reads only the tokens of the current chunk. All tokens before the start of the chunk are skipped (vllm/v1/core/single_type_kv_cache_manager.py:1418-1421). Its prefix hit counts all blocks before the current chunk as computed and fills them with the null block (vllm/v1/core/single_type_kv_cache_manager.py:1345-1375).

MambaManager. A Mamba layer keeps one Mamba state per request, not one entry per token. The manager needs only the state after the last computed token, so it skips num_computed_tokens − 1 tokens (vllm/v1/core/single_type_kv_cache_manager.py:1966-1971). What it keeps depends on mamba_cache_mode:

The manager refuses prefix caching in any other mode (vllm/v1/core/single_type_kv_cache_manager.py:1437-1440).

Step 5: prefix caching across groups#

The block pool keys every cached block by (block hash, group id), so each group caches and evicts its own blocks (vllm/v1/core/kv_cache_utils.py:60-69). A hit is useful only if every group has the blocks for it. The coordinator finds the longest such length with a fixed-point loop:

        while True:
            curr_hit_length = hit_length

            for idx, (spec, group_ids, manager_cls, use_eagle) in enumerate(
                self.attention_groups
            ):
                first_group_id = group_ids[0]
                # DCP/PCP shard each block's KV across ranks, so the manager's
                # effective block size may exceed the spec's.
                group_block_size = self.single_type_managers[first_group_id].block_size
                cached_blocks = hit_blocks_by_group[first_group_id]
                if isinstance(spec, FullAttentionSpec) and cached_blocks is not None:
                    # Full attention is downward-closed: we only need to look
                    # up cached blocks once; on subsequent iterations just trim
                    # to the (reduced) current hit length.
                    curr_hit_length = min(
                        curr_hit_length, hit_length_by_group[first_group_id]
                    )
                    continue

                drop_eagle_block = use_eagle and idx not in eagle_verified

                _max_length = curr_hit_length
                # Eagle matches one extra drop unit (one hash unit for
                # fine-grained managers, else one cache block) and then drops
                # it, landing back at the candidate length. No margin for
                # mamba: its finder never drops (draft models have no mamba
                # layers), so the hit would grow past the candidate.
                if drop_eagle_block and not isinstance(spec, MambaSpec):
                    eagle_margin = eagle_proof_margin(
                        group_block_size,
                        self.hash_block_size,
                        self.enable_partial_hash_hits
                        and manager_cls.supports_fine_grained_hash_lookup,
                    )
                    _max_length = min(
                        curr_hit_length + eagle_margin,
                        len(block_hashes) * self.hash_block_size,
                    )
                hit_blocks, _new_hit_length = manager_cls.find_longest_cache_hit(
                    block_hashes=block_hashes,
                    max_length=_max_length,
                    kv_cache_group_ids=group_ids,
                    block_pool=self.single_type_managers[first_group_id].block_pool,
                    kv_cache_spec=spec,
                    drop_eagle_block=drop_eagle_block,
                    alignment_tokens=self._cache_hit_alignment_tokens,
                    dcp_world_size=self.single_type_managers[
                        first_group_id
                    ].dcp_world_size,
                    pcp_world_size=self.single_type_managers[
                        first_group_id
                    ].pcp_world_size,
                )
                if drop_eagle_block:
                    eagle_verified.add(idx)
                elif _new_hit_length < curr_hit_length:
                    # length shrunk; invalidate previous eagle verifications
                    eagle_verified.clear()
                curr_hit_length = _new_hit_length
                for group_id, blocks in zip(group_ids, hit_blocks):
                    hit_blocks_by_group[group_id] = blocks
                    hit_length_by_group[group_id] = _new_hit_length

                longest_hit_length = max(longest_hit_length, curr_hit_length)

            if curr_hit_length >= hit_length:
                break
            hit_length = curr_hit_length
            if is_simple_hybrid:
                break
  1. The attention groups are sorted so that full attention comes first (vllm/v1/core/kv_cache_coordinator.py:746-750). Its left-to-right walk gives the first upper bound.
  2. Lines 848-855. Full attention is "downward-closed": a hit of length L is also a hit of every shorter length. So the code looks it up once and later only cuts it.
  3. Lines 876-890, 896. Each other group searches a hit of at most the current length, and may return a shorter one.
  4. Lines 903-907. If no group made the length shorter, the loop ends. A model with one full group plus one other group needs only one pass.

After the loop, the code cuts the full-attention block lists to the final length (vllm/v1/core/kv_cache_coordinator.py:909-922). Some group can have a longer hit than the final one. The difference is an "uncached common prefix", and the scheduler stores its end as shared_prefix_boundary (vllm/v1/core/kv_cache_coordinator.py:924-931, vllm/v1/core/kv_cache_manager.py:312-321, vllm/v1/core/sched/scheduler.py:1019-1024). Later, cache_blocks() keeps a hash at that boundary, so the next request can hit it in every group (vllm/v1/core/single_type_kv_cache_manager.py:510-512).

The sliding-window search is different from the full-attention walk. A hit at length L needs only the blocks of the last sliding_window − 1 tokens before L. So the manager searches from right to left for enough contiguous cached blocks, and fills the rest with the null block:

        # The number of contiguous blocks needed for a prefix cache hit.
        sliding_window_contiguous_blocks = cls._contiguous_blocks_for_hit(
            kv_cache_spec.sliding_window, kv_cache_spec.block_size, drop_eagle_block
        )

        # TODO: reduce i by sliding_window_contiguous_blocks when cache miss, to
        # optimize the time complexity from O(max_num_blocks) to
        # O(max_num_blocks / sliding_window_contiguous_blocks +
        # sliding_window_contiguous_blocks),
        # which is good for low cache hit rate scenarios.
        max_num_blocks = max_length // kv_cache_spec.block_size
        computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
            [block_pool.null_block] * max_num_blocks
            for _ in range(len(kv_cache_group_ids))
        )
        block_size = kv_cache_spec.block_size
        num_contiguous_blocks = 0
        match_found = False
        # Search from right to left and early stop when a match is found.
        for i in range(max_num_blocks - 1, -1, -1):
            if cached_block := block_pool.get_cached_block(
                block_hashes[i], kv_cache_group_ids
            ):
                # Skip prefix matching check if the block is not aligned with
                # `alignment_tokens`.
                if num_contiguous_blocks == 0 and block_size != alignment_tokens:
                    post_pop_blocks = i if drop_eagle_block else i + 1
                    if (post_pop_blocks * block_size) % alignment_tokens != 0:
                        continue
                # Add the cached block to the computed blocks.
                for computed, cached in zip(computed_blocks, cached_block):
                    computed[i] = cached
                num_contiguous_blocks += 1
                if num_contiguous_blocks >= sliding_window_contiguous_blocks:
                    # Trim the trailing blocks.
                    # E.g., [NULL, NULL, 8, 3, NULL, 9] -> [NULL, NULL, 8, 3]
                    # when sliding_window_contiguous_blocks=2.
                    for computed in computed_blocks:
                        del computed[i + num_contiguous_blocks :]
                    match_found = True
                    break

Not every sliding-window or Mamba block gets a hash. With the default --prefix-cache-retention-interval 0, these groups keep hashes only at "semantic checkpoints": the end of the prompt (num_prompt_tokens − 1) and shared-prefix junctions (vllm/config/cache.py:272-278, vllm/v1/core/kv_cache_coordinator.py:65-86). A mask in cache_blocks() leaves out the other blocks (vllm/v1/core/single_type_kv_cache_manager.py:1131-1142, vllm/v1/core/block_pool.py:277-278).

Step 6: when the hybrid manager or prefix caching is off#

Prefix caching. The default comes from is_prefix_caching_supported. Generative decoder models, including sliding-window ones, and generative attention + Mamba hybrids get it. Hybrid pooling models, attention-free models and encoder-decoder models do not (vllm/config/model.py:2272-2291). With prefix caching off, the manager uses KVCacheCoordinatorNoPrefixCache for any number of groups (vllm/v1/core/kv_cache_coordinator.py:476-481).

The hybrid manager. --disable-hybrid-kv-cache-manager has the default None, and VllmConfig decides (vllm/config/vllm.py:2202-2273):

  • off on a platform without hybrid support; the base Platform says no, CUDA says yes (vllm/platforms/interface.py:1351-1353, vllm/platforms/cuda.py:683-684);
  • off for chunked local attention with EAGLE, or when VLLM_ALLOW_CHUNKED_LOCAL_ATTN_WITH_HYBRID_KV_CACHE=0 (default 1, vllm/envs.py:1929-1931);
  • off when the KV connector does not support it, with a WARNING. vLLM checks the connector only when neither flag is given;
  • an explicit --enable-hybrid-kv-cache-manager in one of the first two cases raises ValueError.

When it is off, sliding-window and chunked-local specs become full-attention specs, so every layer keeps every block. The attention still computes only the window (vllm/v1/core/kv_cache_planning.py:853-876). Mamba layers cannot be converted, so attention + Mamba models need the hybrid manager (vllm/config/vllm.py:2245-2256).

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

Worked example: gpt-oss-20b on one GPU

The model numbers are from the public Hugging Face config of gpt-oss-20b, not from the vLLM repo. They are 24 layers, sliding window 128, 8 KV heads, head size 64, BF16 KV cache, and max_position_embeddings 131,072. vLLM puts the window on every even layer, so 12 layers are sliding and 12 are full (vllm/model_executor/models/gpt_oss.py:174-175). The memory size, the batched tokens and block size 16 are assumptions. The attention backend can choose another block size (vllm/model_executor/layers/attention/attention.py:621-660).

  1. Specs. 12 SlidingWindowSpec(sliding_window=128) and 12 FullAttentionSpec, block size 16.
  2. Page. 8 × 16 × (64 + 64) × 2 bytes = 32 KiB for both types. The pages are equal, so no layer changes its block size.
  3. Groups. Buckets of 12 and 12: 12 < 1.5 × 12, so the group size is 12. Two groups, no padding.
  4. Bytes per block. 12 × 32 KiB = 384 KiB (vllm/v1/core/kv_cache_planning.py:578-586). Without the hybrid manager, one group of 24 layers: 768 KiB.
  5. Blocks. With 40 GiB for the KV cache: 41,943,040 KiB / 384 KiB = 109,226 blocks.
  6. One request of 1,200 tokens. Full group: cdiv(1200, 16) = 75 blocks. Sliding group: 1200 − 128 + 1 = 1073 skipped tokens, so 67 null places and 8 real blocks. Total 83 blocks = 31.1 MiB. Without the hybrid manager: 75 blocks of 768 KiB = 56.25 MiB.
  7. Capacity at max_model_len 131,072 (from the config). Assume 8,192 batched tokens and async scheduling, so 16,384 in-flight tokens (vllm/config/vllm.py:586-608). Full group: 8,192 blocks per request. Sliding group: cdiv(min(127 + 16384, 131072), 16) + 1 = 1,033 (vllm/v1/kv_cache_interface.py:922-929). Together 9,225 blocks, so the start-up log shows about 109,226 / 9,225 = 11.8x concurrency (vllm/v1/core/kv_cache_utils.py:1030-1047, vllm/v1/core/kv_cache_utils.py:1101-1107). Without the hybrid manager: 54,613 blocks / 8,192 = 6.7x.

All numbers after the config values are derived from the cited lines.

Deep dive

Grouping with uneven layer counts

Gemma-3-27b has 52 sliding-window and 10 full-attention layers, according to the design document (docs/design/hybrid_kv_cache_manager.md:113). The code at vllm/v1/core/kv_cache_planning.py:521-556 does this:

  1. Smallest bucket 10, largest 52. 52 is not below 1.5 × 10, so the group size is 10.
  2. Full attention: 10 layers, one group.
  3. Sliding window: cdiv(52, 10) = 6 groups. The code logs "Add 8 padding layers, may waste at most 15.38% KV cache memory".
  4. Line 555 takes layers[i::6]: groups of 9, 9, 9, 9, 8 and 8 layers. The bytes per block come from the largest group, the full group with 10 layers. So the smaller groups leave part of each block empty.

The design document draws the six sliding groups as consecutive runs: five groups of 10 and one group of 2 + 8 padding (docs/design/hybrid_kv_cache_manager.md:115-121). The code interleaves the layers instead (lines 543-555). The padding is the same: 7 groups × 10 layer slots for 62 layers, so 8 empty slots.

Deep dive

Mamba prefix caching in align mode

A Mamba state is the result of all earlier tokens, like a hash chain. To resume a new request at token t, the engine needs the state exactly after token t. In "align" mode, vLLM keeps the state of the last token of each step and of each block boundary (vllm/config/cache.py:301-308).

With fine-grained hashing (--prefix-match-unit smaller than the Mamba block), hits can also end inside a block (vllm/v1/core/kv_cache_utils.py:762-801, vllm/v1/core/single_type_kv_cache_manager.py:1496-1516).

Deep dive

Errors and edge cases

SituationResultWhere
Pages cannot be made equal (MLA next to another page size)NotImplementedError, then a full-allocation fallback or the errorvllm/v1/core/kv_cache_planning.py:400-405, vllm/v1/core/kv_cache_planning.py:1169-1175
Hybrid manager off, specs still not uniform (Mamba)ValueError "Failed to promote local KV cache specs"vllm/v1/core/kv_cache_planning.py:810-814
--enable-hybrid-kv-cache-manager where it is not supportedValueErrorvllm/config/vllm.py:2260-2269
--prefix-match-unit does not divide a group block sizeValueErrorvllm/v1/core/kv_cache_utils.py:892-898
Mamba prefix caching without chunked prefillAssertionErrorvllm/model_executor/models/config.py:771-773
Uneven layer counts per typeWARNING "Add N padding layers"vllm/v1/core/kv_cache_planning.py:535-541
Decode context parallel with sliding windowAssertionErrorvllm/v1/core/single_type_kv_cache_manager.py:980

What can go wrong#

  • Less KV capacity than expected with a KV connector. A connector without hybrid support turns the hybrid manager off. Sliding-window layers then keep every block (vllm/config/vllm.py:2236-2256). Check the start-up log for "Turning off hybrid kv cache manager".
  • An attention + Mamba model fails at start-up with a connector. The same warning; such models need the hybrid manager.
  • Prefix hits are shorter on a sliding-window model. With retention interval 0, sliding-window blocks keep hashes only at the end of a prompt and at shared-prefix junctions. A hit must end where all groups have blocks (Step 5). --prefix-cache-retention-interval keeps more checkpoints (vllm/config/cache.py:272-278).
  • A large attention block size on a Mamba hybrid. The platform logs "Setting attention block size to N tokens" (vllm/platforms/interface.py:1103-1109). Larger blocks make prefix hits coarser.

Why it matters. For models with sliding-window or Mamba layers, the hybrid manager decides how many requests fit in the KV cache. To size a deployment, you must know which layers keep every block, which free them, and when the manager is off.

Check yourself#

  1. 1
    Why do all KV cache groups need the same page size?
    easy
    Show answer

    All groups take block ids from one block pool, and their KV cache tensors use the same bytes from offset 0 (vllm/v1/core/kv_cache_planning.py:685-694). A block id belongs to one group at a time, so it must mean the same number of bytes in every group (vllm/v1/kv_cache_interface.py:1465-1467).

  2. 2
    A sliding-window layer has a window of 128 and block size 16. A request has processed 300 tokens. How many leading places of its block table hold the null block?
    medium
    Show answer

    The skipped tokens are 300 − 128 + 1 = 173 (vllm/v1/core/single_type_kv_cache_manager.py:1176-1179). That is 173 // 16 = 10 whole blocks, so 10 places hold the null block (vllm/v1/core/single_type_kv_cache_manager.py:713-719).

  3. 3
    What happens to a sliding-window model when you pass --disable-hybrid-kv-cache-manager?
    medium
    Show answer

    The sliding-window specs become full-attention specs, so all layers form one group and keep every block. The attention still computes only the window (vllm/v1/core/kv_cache_planning.py:853-876, vllm/v1/core/kv_cache_planning.py:785-808).

  4. 4
    In the gpt-oss-20b deep dive, why does the sliding-window group need 1,033 blocks per request at start-up, and not cdiv(128, 16) = 8?
    hard
    Show answer

    The group frees blocks only for processed tokens. Blocks for every in-flight token stay allocated: up to max_concurrent_batches × max_num_batched_tokens (vllm/config/vllm.py:600-608). The cap adds them to window − 1, plus one block because the window may start inside a block (vllm/v1/kv_cache_interface.py:922-929).

  5. 5
    A model has one full-attention group and one sliding-window group. The full-attention walk finds 96 cached tokens, but the sliding-window group has a valid hit only at 64 tokens. What is the hit, and what does the scheduler remember?
    hard
    Show answer

    The hit is 64 tokens: the sliding-window group cuts the candidate length, and the full-attention blocks are cut to 64 (vllm/v1/core/kv_cache_coordinator.py:896, vllm/v1/core/kv_cache_coordinator.py:909-922). The longest group hit was 96, so 32 tokens are an uncached common prefix (vllm/v1/core/kv_cache_coordinator.py:927). The request stores shared_prefix_boundary = 64 + 32 = 96 (vllm/v1/core/kv_cache_manager.py:316-318). When its blocks get hashes, the sliding-window mask keeps the blocks that end at 96, so a later request can hit 96 tokens (vllm/v1/core/single_type_kv_cache_manager.py:510-512, vllm/v1/core/single_type_kv_cache_manager.py:1131-1140).