KV Cache Connector under VSWA#

Source NVIDIA/TensorRT-LLM.

  1# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
  2# SPDX-License-Identifier: Apache-2.0
  3#
  4# Licensed under the Apache License, Version 2.0 (the "License");
  5# you may not use this file except in compliance with the License.
  6# You may obtain a copy of the License at
  7#
  8# http://www.apache.org/licenses/LICENSE-2.0
  9#
 10# Unless required by applicable law or agreed to in writing, software
 11# distributed under the License is distributed on an "AS IS" BASIS,
 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 13# See the License for the specific language governing permissions and
 14# limitations under the License.
 15"""KV connector for a cache with one attention window size per layer group.
 16
 17`llm_kv_cache_connector.py` is the connector to start from. It works whenever a
 18single tensor can describe the whole KV cache, which is every model with one
 19attention window size. This one covers what changes when that stops being true
 20-- variable sliding-window attention (VSWA), where the cache allocates one pool
 21per window size.
 22
 23Five things differ, and each is marked `VSWA:` below.
 24
 251. Pages are addressed per layer group. A page index is scoped to a group, so
 26   the flat `block_ids` list does not exist and `register_kv_caches` is never
 27   called; `register_kv_cache_layout` is the entry point.
 282. Every block-id callback switches to its `*_by_layer_group` form.
 293. A cache key must include the layer group. The same token range lives in every
 30   group holding *different* KV, so a key derived from tokens alone collides
 31   across groups and one group's bytes overwrite another's.
 324. A sliding group offers only its live window to save. Blocks the window has
 33   passed report no page and hold no readable KV, so `valid_page_slots` drops
 34   them and a connector persists at most `window_size` tokens per sequence for
 35   such a group -- not `prompt_len`. Size the store for that.
 365. A block is only servable when *every* group holds it. The full-attention
 37   group keeps the whole prompt while the sliding group keeps a tail, so the
 38   prefix this connector can serve back is bounded by the smallest window. The
 39   lookup below intersects across groups and stops at the first ordinal any
 40   group misses.
 41
 42The cache key covers one block's tokens, as in the flat example. That assumes a
 43block's tokens determine its KV, which two prompts sharing a block but not the
 44prefix before it break: the second reads back KV computed under the first one's
 45prefix. A production connector chains the prefix into the key.
 46
 47Run with a VSWA model, for example:
 48
 49    python llm_kv_cache_connector_vswa.py --model <path-to-gemma-3> \
 50        --max-attention-window 1024 1024 1024 1024 1024 32768
 51"""
 52
 53import hashlib
 54import os
 55import tempfile
 56from dataclasses import dataclass, field
 57from pathlib import Path
 58from typing import Dict, List, Optional, Tuple
 59
 60import click
 61import torch
 62
 63from tensorrt_llm import LLM, SamplingParams, logger
 64from tensorrt_llm._torch.pyexecutor.connectors.kv_cache_connector import (
 65    KvCacheConnectorScheduler,
 66    KvCacheConnectorWorker,
 67    SchedulerOutput,
 68)
 69from tensorrt_llm._torch.pyexecutor.connectors.kv_cache_layout import (
 70    KvCacheRegion,
 71    valid_page_slots,
 72)
 73from tensorrt_llm.bindings.internal.batch_manager import LlmRequest
 74from tensorrt_llm.llmapi.llm_args import KvCacheConfig, KvCacheConnectorConfig, TorchLlmArgs
 75
 76CONNECTOR_CACHE_FOLDER_KEY = "CONNECTOR_CACHE_FOLDER"
 77
 78
 79@dataclass
 80class VswaConnectorMetadata:
 81    # (path, layer_group_id, page_slot) -- the group is part of every entry
 82    # because a page slot only means something inside its own group.
 83    load: List[Tuple[str, int, int]] = field(default_factory=list)
 84    save: List[Tuple[str, int, int]] = field(default_factory=list)
 85
 86
 87class VswaKvCacheConnectorWorker(KvCacheConnectorWorker):
 88    def __init__(self, llm_args: TorchLlmArgs):
 89        super().__init__(llm_args)
 90        # VSWA (1): one region per layer group, not one tensor for the whole
 91        # cache. The region is kept rather than a `[num_slots, bytes]` view of
 92        # it, because `slot_tensor` checks the page slot before addressing it
 93        # and a plain view accepts any subscript.
 94        self.group_regions: Dict[int, KvCacheRegion] = {}
 95        self.layer_to_group: Dict[int, int] = {}
 96
 97    def register_kv_cache_layout(self, layout) -> None:
 98        # VSWA (1): a group's buffers may coalesce into several regions, so a
 99        # region is addressed per (group, region). `region.slot_tensor(i)` is the
100        # bytes of page slot `i` of that group.
101        for group in layout.groups:
102            if len(group.regions) != 1:
103                raise NotImplementedError(
104                    f"layer group {group.layer_group_id} has "
105                    f"{len(group.regions)} regions; this example handles one. "
106                    "Address `group.regions` individually to support more."
107                )
108            self.group_regions[group.layer_group_id] = group.regions[0]
109            for layer_id in group.layer_ids:
110                self.layer_to_group[layer_id] = group.layer_group_id
111            logger.info(
112                f"layer group {group.layer_group_id}: window={group.window_size}, "
113                f"{len(group.layer_ids)} layers, {group.bytes_per_page} bytes per page"
114            )
115
116    def start_load_kv(self, stream: torch.cuda.Stream):
117        for path, group_id, slot in self._metadata.load:
118            cpu_tensor = torch.load(path, map_location="cpu", weights_only=True)
119            self.group_regions[group_id].slot_tensor(slot).copy_(cpu_tensor, non_blocking=False)
120
121    def wait_for_layer_load(self, layer_idx: int, stream: torch.cuda.Stream):
122        # `layer_to_group` is what turns a per-layer hook into the group whose
123        # pages that layer reads. A connector that overlapped the transfer with
124        # compute would wait here only on the group this layer belongs to.
125        pass
126
127    def save_kv_layer(self, layer_idx: int, stream: torch.cuda.Stream):
128        pass
129
130    def wait_for_save(self, stream: torch.cuda.Stream):
131        stream.synchronize()
132        for path, group_id, slot in self._metadata.save:
133            if Path(path).exists():
134                continue
135            torch.save(self.group_regions[group_id].slot_tensor(slot).cpu(), path)
136
137    def get_finished(
138        self, finished_gen_req_ids: List[int], started_loading_req_ids: List[int]
139    ) -> Tuple[List[int], List[int]]:
140        return [], []
141
142
143class VswaKvCacheConnectorLeader(KvCacheConnectorScheduler):
144    def __init__(self, llm_args: TorchLlmArgs):
145        super().__init__(llm_args)
146        self.block_size = self._llm_args.kv_cache_config.tokens_per_block
147        # VSWA (5): the lookup has to check every group, so the leader needs the
148        # group count before the first request, and the configured window list
149        # is all it has that early. That list is not what the cache groups on:
150        # two configured windows at or above `max_seq_len` describe one group,
151        # not two. Count effective windows instead.
152        windows = self._llm_args.kv_cache_config.max_attention_window or [None]
153        max_seq_len = self._llm_args.max_seq_len
154        self.num_layer_groups = len(
155            {self._effective_window(window, max_seq_len) for window in windows}
156        )
157        # request_id -> list of per-group file paths, one entry per matched block
158        # ordinal, in ordinal order starting at the first locally uncomputed one.
159        self.pending_loads: Dict[int, List[List[str]]] = {}
160        self.cache_folder = os.environ.get(CONNECTOR_CACHE_FOLDER_KEY, "./connector_cache")
161        os.makedirs(self.cache_folder, exist_ok=True)
162
163    @staticmethod
164    def _effective_window(window: Optional[int], max_seq_len: Optional[int]) -> Optional[int]:
165        """The window a layer group is formed on, not the one that was configured.
166
167        Mirrors `_resolve_v2_max_attention_window_vec`: clamp to `max_seq_len`,
168        then report full attention (`None`) for a window that reaches it.
169        `max_seq_len` is `None` when it was inferred from the model rather than
170        configured, and the count then falls back to the configured values --
171        `build_connector_meta` rejects the request if that disagrees with the
172        cache.
173        """
174        if window is None or window <= 0:
175            return None
176        if max_seq_len is None:
177            return int(window)
178        return None if int(window) >= int(max_seq_len) else int(window)
179
180    # VSWA (3): the group id goes into the key. Without it, group 0 and group 1
181    # hash the same tokens to the same file and overwrite each other's KV.
182    def _file_path(self, tokens: List[int], layer_group_id: int, salt: Optional[str]) -> str:
183        digest = hashlib.sha256(repr((tokens, layer_group_id, salt)).encode()).hexdigest()
184        return os.path.join(self.cache_folder, f"{digest}.pt")
185
186    def _chunk_tokens(self, tokens: List[int]) -> List[List[int]]:
187        return [tokens[i : i + self.block_size] for i in range(0, len(tokens), self.block_size)]
188
189    def get_num_new_matched_tokens(
190        self, request: LlmRequest, num_computed_tokens: int
191    ) -> Tuple[int, bool]:
192        self.pending_loads[request.request_id] = []
193
194        # Partial blocks are not stored, so a partial local match has nothing
195        # to append to.
196        if num_computed_tokens % self.block_size != 0:
197            return 0, False
198
199        computed_blocks = num_computed_tokens // self.block_size
200        remaining = request.get_tokens(0)[computed_blocks * self.block_size :]
201
202        for chunk in self._chunk_tokens(remaining):
203            if len(chunk) != self.block_size:
204                break
205            paths = [
206                self._file_path(chunk, group_id, request.cache_salt)
207                for group_id in range(self.num_layer_groups)
208            ]
209            # VSWA (5): every group or none. A block the sliding group dropped
210            # is unservable even though the full-attention group still has it,
211            # because the sliding layers would then attend to KV that was never
212            # written.
213            if not all(Path(path).exists() for path in paths):
214                break
215            self.pending_loads[request.request_id].append(paths)
216
217        matched = len(self.pending_loads[request.request_id]) * self.block_size
218        logger.info(
219            f"VSWA KV CONNECTOR: matched {matched} tokens "
220            f"({len(self.pending_loads[request.request_id])} blocks x "
221            f"{self.num_layer_groups} groups) for request {request.request_id}"
222        )
223        return matched, False
224
225    def update_state_after_alloc_by_layer_group(
226        self, request: LlmRequest, block_ids_by_layer_group: List[List[int]]
227    ) -> None:
228        # VSWA (2): the flat `update_state_after_alloc` is never called here.
229        pass
230
231    def build_connector_meta(self, scheduler_output: SchedulerOutput):
232        # NOTE: This is a simplified implementation, and does not work with
233        # chunked prefill. A request appears in `new_requests` once, carrying
234        # the pages allocated so far; with chunked prefill the later chunks'
235        # pages arrive under `cached_requests`, whose entries this loop never
236        # reads. Blocks whose page is not yet allocated drop out of
237        # `valid_by_group` below and are never revisited, so those chunks go
238        # unsaved.
239
240        metadata = VswaConnectorMetadata()
241
242        for req in scheduler_output.new_requests:
243            pending_load = self.pending_loads.pop(req.request_id, [])
244            by_group = req.new_block_ids_by_layer_group
245            # VSWA (2): the flat list is empty with several groups.
246            if len(by_group) != self.num_layer_groups:
247                raise RuntimeError(
248                    f"expected {self.num_layer_groups} layer groups from the "
249                    f"cache, got {len(by_group)}. The window list this leader "
250                    "derived its group count from does not describe the cache."
251                )
252
253            # `computed_position` excludes what the connector said it would
254            # serve, so this is where the locally computed prefix ends and the
255            # matched blocks begin.
256            num_computed_blocks = req.computed_position // self.block_size
257
258            # VSWA (4): a sliding group holds pages for its live window only, so
259            # its list reports no page for the ordinals the window has passed.
260            # Those ordinals stay in place to keep entry `i` describing the same
261            # token range, and drop out here so no transfer targets them.
262            valid_by_group = [dict(valid_page_slots(slots)) for slots in by_group]
263
264            for offset, paths in enumerate(pending_load):
265                ordinal = num_computed_blocks + offset
266                for group_id, path in enumerate(paths):
267                    slot = valid_by_group[group_id].get(ordinal)
268                    if slot is None:
269                        continue
270                    metadata.load.append((path, group_id, slot))
271
272            chunks = self._chunk_tokens(req.new_tokens)
273            for ordinal in range(num_computed_blocks + len(pending_load), len(chunks)):
274                if len(chunks[ordinal]) != self.block_size:
275                    continue
276                for group_id, valid_slots in enumerate(valid_by_group):
277                    slot = valid_slots.get(ordinal)
278                    if slot is None:
279                        continue
280                    path = self._file_path(chunks[ordinal], group_id, req.cache_salt)
281                    metadata.save.append((path, group_id, slot))
282
283        return metadata
284
285    def request_finished_by_layer_group(
286        self, request: LlmRequest, cache_block_ids_by_layer_group: List[List[int]]
287    ) -> bool:
288        # VSWA (2) and (4): per group, and a sliding group's list covers its live
289        # window only -- everything older is -1 and holds no readable KV.
290        self.pending_loads.pop(request.request_id, None)
291        return False
292
293
294def build_llm(
295    model: str,
296    max_attention_window: List[int],
297    max_seq_len: Optional[int] = None,
298    free_gpu_memory_fraction: float = 0.5,
299    use_kv_cache_manager_v2: "bool | str" = True,
300    enable_block_reuse: bool = True,
301):
302    """An `LLM` wired to this connector. Shared by `main` and the e2e test."""
303    connector_config = KvCacheConnectorConfig(
304        connector_module=__name__,
305        connector_scheduler_class="VswaKvCacheConnectorLeader",
306        connector_worker_class="VswaKvCacheConnectorWorker",
307    )
308    return LLM(
309        model=model,
310        backend="pytorch",
311        cuda_graph_config=None,
312        disable_overlap_scheduler=True,
313        max_seq_len=max_seq_len,
314        kv_cache_config=KvCacheConfig(
315            free_gpu_memory_fraction=free_gpu_memory_fraction,
316            # One window size per layer, repeated cyclically. More than one
317            # distinct value is what makes the cache allocate a layer group
318            # per window -- and what this example exists to demonstrate.
319            max_attention_window=list(max_attention_window),
320            # VSWA needs the layout-describing registration path. "auto" is
321            # enough for a model that declares the preference itself, which
322            # Gemma-3 does.
323            use_kv_cache_manager_v2=use_kv_cache_manager_v2,
324            enable_block_reuse=enable_block_reuse,
325        ),
326        kv_connector_config=connector_config,
327    )
328
329
330@click.command()
331@click.option("--model", type=str, required=True)
332@click.option("--max-attention-window", type=int, multiple=True, required=True)
333def main(model: str, max_attention_window: Tuple[int, ...]):
334    with tempfile.TemporaryDirectory() as cache_folder:
335        os.environ[CONNECTOR_CACHE_FOLDER_KEY] = cache_folder
336        prompt = "The future of AI is"
337        params = SamplingParams(max_tokens=16, ignore_eos=True)
338
339        # Cold: nothing is cached, so every full block is saved.
340        llm = build_llm(model, list(max_attention_window))
341        try:
342            print("cold:", llm.generate([prompt], params)[0].outputs[0].text)
343        finally:
344            llm.shutdown()
345
346        # Warm: a fresh instance shares only the disk cache, so anything served
347        # back came through the connector.
348        llm = build_llm(model, list(max_attention_window))
349        try:
350            print("warm:", llm.generate([prompt], params)[0].outputs[0].text)
351        finally:
352            llm.shutdown()
353
354
355if __name__ == "__main__":
356    main()