DeepInfer Explained

Cross-layer KV sharing

AdvancedDeepInfer conceptsabout 8 min read + 5 min deep dives

Code version: DeepInfer @ 9bd9cf2 · 2026-10-21

In one sentence

A source layer computes and stores a value once; the next layers in its segment read the same cache or list instead of their own.

The idea#

Neighbour layers often need very similar keys. Cross-layer KV sharing uses this. A source layer computes a value and stores it. The layers right after it, its consumers, read that value and do not compute or store their own copy. A source and its consumers form a segment.

DeepInfer can share three values, each with its own map:

  1. KV (layer_kv_map): the compressed KV in ca_cache, and its compression state.
  2. Index K (layer_index_k_map): the keys of the indexer in ica_cache.
  3. Top-k (layer_index_module_map): the whole indexer and its result, the list of selected keys.

The sliding-window cache is never shared. Each layer also keeps its own query and its own attention output. So a consumer reads the same keys as its source, but asks its own question.

An everyday comparison. Think of a team that shares one set of notes. One person (the source) takes the notes; the next people read them and do not write their own. Each person still writes a personal report (the query and the output). The comparison stops here: the team order is fixed, and a reader can only use the notes of the nearest note-taker before it.

The picture#

Figure: an example sharing config with 8 layers (made-up values). Look at layer 2: it has its own indexer, but it reads the KV and the index keys of layer 1. Click to zoom.
Open full size ↗

Panel 1 shows the three maps for the example. A solid cell means "own"; a dashed cell names the source. Panel 2 shows how one forward moves values from layer 3 to layer 4. The real source lists come from config.json and are not in the repo.

In the code#

Step 1: config.json gives lists of source layers#

def _source_map(source_ids: list[int], n_layer: int) -> dict[int, int]:
    """Map each layer to the nearest source layer that is not later than that layer."""
    ids = sorted(source_ids)
    return {layer: max(s for s in ids if s <= layer) for layer in range(ids[0], n_layer)}


def _shared_layer_cfg(text: dict[str, Any]) -> AttrDict:
    n_layer = text["num_hidden_layers"]
    candidate_source = text["candidate_source_layer_id"]
    shared = AttrDict(
        layer_index_module_map=_source_map(text["index_source_layer_ids"], n_layer),
        layer_kv_map=_source_map(text["kv_source_layer_ids"], n_layer),
        layer_index_k_map=_source_map(text.get("index_k_source_layer_ids", text["kv_source_layer_ids"]), n_layer),
    )
    if candidate_source is not None:
        shared.layer_candidate_source_map = {layer: candidate_source for layer in range(candidate_source, n_layer)}
        shared.candidate_source_cfg = {
            candidate_source: AttrDict(
                candidate_topk_blocks=text["candidate_topk_blocks"],
                candidate_block_size=text["candidate_block_size"],
                topk_block_score_reduce="max",
            )
        }
    return shared

_source_map maps each layer from the first source to the end to the nearest source at or before it. Example: sources [1, 3, 5, 6] with 8 layers give {1: 1, 2: 1, 3: 3, 4: 3, 5: 5, 6: 6, 7: 6}. If config.json has no index_k_source_layer_ids, the index-K map uses the KV sources (line 74). Every compressed layer gets the same layer_info_shared_cfg (deepinfer/hugging_face/config.py:129). SWA-only layers get none (deepinfer/hugging_face/config.py:99-108).

Step 2: SharedLayerTopology makes and checks the maps#

The model builds one SharedLayerTopology at start-up, for the main layers and the draft layers (deepinfer/model_executor/models/v4_modules/cross_layer.py:339-351). It turns each map into a list over all layers. A layer that is not in the map is its own source:

        raw = shared_cfg.get(name)
        # The default value of layer_index_k_map is layer_index_module_map.
        if raw is None and name == "layer_index_k_map":
            raw = shared_cfg.get("layer_index_module_map")
        assert raw is not None, f"shared layer config missing {name}"
        assert all(0 <= layer_id < num_layers and 0 <= src < num_layers for layer_id, src in raw.items()), (
            f"{name} contains invalid layer ids or sources, num_layers={num_layers}"
        )
        layer_map = [raw.get(layer_id, layer_id) for layer_id in range(num_layers)]
        assert all(layer_map[layer_id] == layer_id for layer_id in range(n_main_layer, num_layers)), (
            f"{name} sharing on MTP layers is not supported"
        )
        return layer_map

Line 151 fills the gaps with self-references. Lines 152-154 forbid sharing in the draft (MTP) layers. Then _validate checks the rules (deepinfer/model_executor/models/v4_modules/cross_layer.py:89-118). Each map must be contiguous segments. Each consumer must carry the shared config, with the same compress ratio and index_topk as its source. KV consumers must have the same SWA setting. The maps must nest. The deep dive "The validation rules" gives the details.

Step 3: the cache engine allocates caches only for sources#

        # ---- Index Attention ----
        # NOTE index K 是否复用只由 index-K map 决定,和整个 index module / main kv map 解耦:
        #   index_mapped_layer_id != layer_id 时复用别层 indexer 的 ica_cache,本层不重复存 index K;
        #   否则本层拥有 index kv;只有独立 compressor indexer 需要 index state。
        if GPUCacheInterface._is_sparse(attn_module):
            if index_mapped_layer_id != layer_id:
                # share-index-K:复用别层 indexer 的 K cache,不推进 kv offset / 不 alloc index state
                ica_cache = cache_list[index_mapped_layer_id].ica_cache
            else:
                token_per_block = block_size // ratio if ratio > 0 else block_size
                delta = token_per_block * self._qk_topk_bytes
                if gpu_blocks is not None:
                    ica_cache = gpu_blocks[:, self._kv_offset : self._kv_offset + delta].view(-1, token_per_block, 1, self._qk_topk_bytes)
                padded_delta = _align_to_multiple(delta, 1, 32)
                self._kv_offset += padded_delta
                kv_length += padded_delta

                if _needs_compress_state(ratio) and _needs_index_state(attn_module):
                    num_state_token = self._state_block.all_compress_block_size[ratio]
                    ica_state = self._alloc_state(num_state_token, self._qk_topk_head_dim)

        # ---- Main Attention ----
        # NOTE main(compress)kv 是否复用只由 kv map 决定,和 index map 解耦。
        if ratio > 0:
            if kv_mapped_layer_id != layer_id:
                ca_cache = cache_list[kv_mapped_layer_id].ca_cache
            else:
                # 所有压缩率(c1/c2)统一处理
                token_per_block = block_size // ratio
                delta = token_per_block * self._bytes_per_token
                if gpu_blocks is not None:
                    ca_cache = gpu_blocks[:, self._kv_offset : self._kv_offset + delta].as_strided(
                        (gpu_blocks.shape[0], token_per_block, 1, self._bytes_per_token),
                        (gpu_blocks.stride(0), self._bytes_per_token, self._bytes_per_token, 1),
                    )
                padded_delta = _align_to_multiple(delta, 1, 32)
                self._kv_offset += padded_delta
                kv_length += padded_delta

                if _needs_compress_state(ratio):
                    num_state_token = self._state_block.all_compress_block_size[ratio]
                    ca_state = self._alloc_state(num_state_token, self._compress_dim)

Translated comments (Chinese → English)

  • 425-427 Whether index K is reused depends only on the index-K map, not on the whole index module or the main KV map. When index_mapped_layer_id != layer_id, the layer reuses the ica_cache of another layer's indexer and does not store index K again; otherwise the layer owns its index KV. Only an independent compressor indexer needs index state.
  • 430 share-index-K: reuse the K cache of another layer's indexer; do not move the kv offset and do not allocate index state.
  • 446 Whether the main (compressed) KV is reused depends only on the KV map, not on the index map.

A consumer gets the same tensor view as its source (lines 431 and 449). It adds no bytes to the block row and no compression state. The engine reads the maps from layer_info_shared_cfg of each layer, and asserts that a source comes before its consumers (deepinfer/worker/cache_engine.py:141-162).

Step 4: only sources get the modules#

The builder passes the source flags to each layer's container. A KV consumer gets no PackedWkv; a top-k consumer gets no indexer (deepinfer/model_executor/models/v4_modules/builder.py:63-73, deepinfer/model_executor/models/v4_modules/mla_attn.py:252-273). An index-source layer that is not an index-K source has an indexer, but it borrows k_proj and k_norm from its index-K source (deepinfer/model_executor/models/v4_modules/builder.py:76-91). At run time, V4Attention reads its three flags from the topology:

        # The sharing of kv and index (topk) can be decoupled: the two sources are not necessarily the same layer.
        self.use_shared_kv = shared_topology is not None
        if shared_topology is None:
            self.is_kv_source = True
            self.is_index_source = True
            self.is_index_k_source = True
        else:
            self.is_kv_source = shared_topology.is_kv_source_layer(layer_idx)
            self.is_index_source = shared_topology.is_index_source_layer(layer_idx)
            self.is_index_k_source = shared_topology.is_index_k_source_layer(layer_idx)

Step 5: values travel through CrossLayerRuntime#

Some values exist only during one forward: the top-k list, the pre-RoPE KV, and the BF16 prefill buffers. DeepseekModel.forward makes one CrossLayerRuntime object and passes it to every layer (deepinfer/model_executor/models/deepseek.py:165-166). A source writes; a consumer reads:

        elif self.is_sparse:
            kv_ca_index = cross_layer_runtime.topk_indices
            assert kv_ca_index is not None, f"layer {self.layer_idx}: index-source topk indices not stored"
        if self.has_local_index:
            cross_layer_runtime.topk_indices = kv_ca_index

Each value has a single slot. This works because the segments are contiguous and the layers run in order, so the value in the slot always comes from the nearest source (deepinfer/model_executor/models/v4_modules/cross_layer.py:284-287).

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

The validation rules

RuleWhyCode
All carried layer_info_shared_cfg copies are equal; at least one existsThe topology keeps only the first copydeepinfer/model_executor/models/v4_modules/cross_layer.py:71-75
Each map is contiguous segments: a consumer points to the nearest source before itOne slot per value in CrossLayerRuntimedeepinfer/model_executor/models/v4_modules/cross_layer.py:169-179
A consumer carries layer_info_shared_cfgWithout it, the cache engine treats the layer as a source and gives it a cache that nobody writesdeepinfer/model_executor/models/v4_modules/cross_layer.py:106-112
Same kv_compress_ratio and index_topk as the source; same use_swa for KVThe shared cache layout and index width must matchdeepinfer/model_executor/models/v4_modules/cross_layer.py:240-261
The first YOCO consumer layer is a KV source"share-kv must not cross the YOCO compaction boundary"deepinfer/model_executor/models/v4_modules/cross_layer.py:114-116
Segments nest: top-k ⊆ index K ⊆ KVIndex K is projected from the source's compressed KV, so it cannot cross a KV segmentdeepinfer/model_executor/models/v4_modules/cross_layer.py:191-197
No sharing in draft layers"The MTP module does not support cross-layer sharing"deepinfer/model_executor/models/v4_modules/cross_layer.py:152-154
A sparse KV source or index source has its own indexer with kprojIt must make pre_rope_kv and the index Kdeepinfer/model_executor/models/v4_modules/attn.py:323-324

The nesting check, step by step. _validate_map_refines tests coarse[fine[l]] == coarse[l] for every layer (deepinfer/model_executor/models/v4_modules/cross_layer.py:182-189). In words: the fine source of layer l must be in the same coarse segment as l.

  1. Example (valid): top-k map [0, 1, 2, 3, 3, 5, 6, 6], index-K map [0, 1, 1, 3, 3, 5, 6, 6]. Layer 2: fine source 2, coarse source of 2 is 1, coarse source of layer 2 is 1. Equal.
  2. Example (invalid): KV sources [1, 4] and index-K sources [1, 3]. Layer 4: index-K source 3, KV source of 3 is 1, KV source of 4 is 4. Not equal, so the assert fails. Layer 4 would read index keys that were projected from another KV segment.

A consequence: every KV source is also an index-K source and an index source (the comment on deepinfer/model_executor/models/v4_modules/cross_layer.py:93-96).

Deep dive

Who owns what: modules, caches and runtime values

ItemOnly inConsumersCode
PackedWkv / CompressorKV sourcenonedeepinfer/model_executor/models/v4_modules/attn.py:345-352
ca_cache bytes, ca_stateKV sourcesame viewdeepinfer/worker/cache_engine.py:447-465
KProject (index-K store)index-K sourceread the source's ica_cachedeepinfer/model_executor/models/v4_modules/index_attn.py:91-94
ica_cache bytesindex-K sourcesame viewdeepinfer/worker/cache_engine.py:428-439
MQA indexer and the index part of q_b_projindex sourceuse topk_indicesdeepinfer/model_executor/models/v4_modules/attn.py:292-299, deepinfer/model_executor/models/v4_modules/attn.py:354-364
swa_cache, query, output projectionevery layer(not shared)deepinfer/worker/cache_engine.py:406-422

Runtime values (deepinfer/model_executor/models/v4_modules/cross_layer.py:289-336):

Deep dive

Memory saved: worked example

Use the example config of the figure (made-up values). It has 8 main layers with ratios [0, 2, 2, 2, 2, 1, 2, 2]. KV and index-K sources are [1, 3, 5, 6]. Assume FP4 entries (288 B) and an example index_head_dim of 128, so one index key takes 128 / 2 + 128 / 32 = 68 B (deepinfer/worker/cache_engine.py:73-80).

Per block rowWithout sharingWith sharing
Main KV, C2 layers (64 × 288 = 18,432 B each)6 layers: 110,592 B3 sources (1, 3, 6): 55,296 B
Main KV, C1 layer 5 (128 × 288 B)36,864 B36,864 B
Index K, C2 layers (64 × 68 = 4,352 B each)6 layers: 26,112 B3 sources: 13,056 B
Index K, C1 layer 5 (128 × 68 B)8,704 B8,704 B
Total182,272 B113,920 B (−37.5 %)

Each part is padded to 32 bytes; these numbers are already multiples of 32 (deepinfer/worker/cache_engine.py:437-439, deepinfer/worker/cache_engine.py:459-461). The whole row is then padded to lcm(128, 288) = 1,152 B (deepinfer/worker/cache_engine.py:481-484). The compression state also shrinks: 3 instead of 6 C2 rings of 28,672 B per state slot (deepinfer/worker/cache_engine.py:190-198). A smaller block row gives more blocks in the same memory, so more tokens fit.

Deep dive

Candidate blocks (an extra sharing feature)

config.json can name one candidate_source_layer_id. The HF loader then marks every layer from that one to the end as using its candidates, with candidate_topk_blocks and candidate_block_size (deepinfer/hugging_face/config.py:76-84). The source layer selects a set of candidate key blocks; later index-source layers score and select top-k only inside that set (deepinfer/model_executor/models/v4_modules/cross_layer.py:49-53).

The checks are in deepinfer/model_executor/models/v4_modules/cross_layer.py:199-238:

  • The candidate source is an index source with index_topk above 0.
  • candidate_topk_blocks is a positive multiple of 8, and the block size is 1 … 16.
  • Consumers have the same compress ratio as the source.
  • The candidate capacity is at least the consumer's index_topk.

The indexer adds more limits, for example 8 or 16 tokens per candidate block (deepinfer/model_executor/models/v4_modules/index_attn.py:101-109). The page v4_modules/index_attn.py: the sparse-attention indexer covers the kernels.

What can go wrong#

Why it matters. Sharing cuts the bytes per token of the paged cache and the work of the compressor and indexer. The price is accuracy that the model must learn in training, and strict rules about the order of layers. When a layer reads strange keys, check its three source ids first.

Check yourself#

  1. 1
    Which cache is never shared between layers?
    easy
    Show answer

    The sliding-window ring (swa_cache). The cache engine allocates it for every SWA layer with no map lookup (deepinfer/worker/cache_engine.py:406-422).

  2. 2
    kv_source_layer_ids = [2, 5] with 8 main layers. Which layers read the compressed KV of layer 2?
    medium
    Show answer

    Layers 3 and 4: _source_map gives {2: 2, 3: 2, 4: 2, 5: 5, 6: 5, 7: 5} (deepinfer/hugging_face/config.py:62-65). Layers 0 and 1 are not in the map, so they are their own sources (deepinfer/model_executor/models/v4_modules/cross_layer.py:151).

  3. 3
    A layer is an index source but not an index-K source. What does it compute itself, and what does it read?
    medium
    Show answer

    It computes its own index query and weights and selects its own top-k (deepinfer/model_executor/models/v4_modules/attn.py:292-299). It reads the index keys of its index-K source: the cache engine gives it that layer's ica_cache (deepinfer/worker/cache_engine.py:429-431), and in prefill it reuses the loaded keys (deepinfer/model_executor/models/v4_modules/index_attn.py:193-196).

  4. 4
    KV sources are [1, 4] and index-K sources are [1, 3]. Is the config valid? Explain with the check.
    hard
    Show answer

    No. _validate_map_refines("index-k", ..., "kv", ...) checks kv[ik[l]] == kv[l]. For layer 4: ik[4] = 3, kv[3] = 1, kv[4] = 4, so the assert fails (deepinfer/model_executor/models/v4_modules/cross_layer.py:185-189, deepinfer/model_executor/models/v4_modules/cross_layer.py:196-197). See the deep dive "The validation rules".

  5. 5
    In the worked example, how many bytes per block row does sharing save, and why does the C1 layer save nothing?
    hard
    Show answer

    182,272 − 113,920 = 68,352 B. Layer 5 is a C1 source. A consumer must have the same compress ratio as its source (deepinfer/model_executor/models/v4_modules/cross_layer.py:253-261). So the C2 layers around it cannot share with it. See the deep dive "Memory saved: worked example".