diff --git a/tests/utils_/test_extensible_tensor.py b/tests/utils_/test_extensible_tensor.py index f5c3aa9fb58..3bce9fc219e 100644 --- a/tests/utils_/test_extensible_tensor.py +++ b/tests/utils_/test_extensible_tensor.py @@ -37,7 +37,105 @@ def test_extensible_tensor_rejects_shrink_and_overflow() -> None: buffer.resize_(512) with pytest.raises(ValueError, match="grow-only"): buffer.resize_(256) - with pytest.raises(ValueError, match="exceeds maximum size"): + with pytest.raises(ValueError, match="exceeds the segment capacity"): buffer.resize_(1025) finally: buffer.free() + + +def test_segments_grow_in_lockstep_and_zero_new() -> None: + """Each segment's committed prefix grows in lockstep. + + Data written to a segment's committed prefix survives a grow; the newly + committed range of each segment is zeroed with `zero_new=True` while old + bytes are preserved. + """ + et = ExtensibleTensor(max_num_bytes=8192, device="cuda", num_segments=2) + try: + assert et.num_segments == 2 + assert et.segment_capacity_bytes == 4096 + + et.resize_per_segment_(256, zero_new=True) + assert et.bytes_per_segment == 256 + assert et.num_bytes == 512 + fv = et.full_view() + assert fv.shape == (8192,) + # Committed prefixes start zeroed. + assert torch.count_nonzero(fv[:256]) == 0 + assert torch.count_nonzero(fv[4096 : 4096 + 256]) == 0 + + pattern_a = torch.arange(256, device="cuda", dtype=torch.uint8) + pattern_b = 255 - pattern_a + fv[:256].copy_(pattern_a) + fv[4096 : 4096 + 256].copy_(pattern_b) + + et.resize_per_segment_(1024, zero_new=True) + fv2 = et.full_view() + assert fv2.data_ptr() == fv.data_ptr() + # Old bytes of both segments preserved; freshly committed ranges zeroed. + assert torch.equal(fv2[:256], pattern_a) + assert torch.equal(fv2[4096 : 4096 + 256], pattern_b) + assert torch.count_nonzero(fv2[256:1024]) == 0 + assert torch.count_nonzero(fv2[4096 + 256 : 4096 + 1024]) == 0 + finally: + et.free() + + +def test_segments_at_granularity_scale() -> None: + """Segments spanning multiple mapping granules commit correctly. + + Uses a segment capacity that is not a multiple of the allocation + granularity, so a granule straddles the segment boundary and is shared by + the first commit of one segment and a later commit of the other -- it must + be mapped exactly once. + """ + probe = ExtensibleTensor(max_num_bytes=1, device="cuda") + granularity = probe.capacity_bytes + probe.free() + # Two segments of 1.5 granules each; the middle granule straddles the + # boundary. + max_num_bytes = 3 * granularity + et = ExtensibleTensor(max_num_bytes=max_num_bytes, device="cuda", num_segments=2) + try: + seg = et.segment_capacity_bytes + assert seg == max_num_bytes // 2 + + step = granularity // 2 + et.resize_per_segment_(step, zero_new=True) + fv = et.full_view() + fv[:step].fill_(1) + fv[seg : seg + step].fill_(2) + + # Grow to the full segment capacity: previously mapped granules + # (including the boundary-straddling one) are reused, new ones are + # committed and zeroed. + et.resize_per_segment_(seg, zero_new=True) + fv2 = et.full_view() + assert torch.all(fv2[:step] == 1) + assert torch.all(fv2[seg : seg + step] == 2) + assert torch.count_nonzero(fv2[step:seg]) == 0 + assert torch.count_nonzero(fv2[seg + step :]) == 0 + finally: + et.free() + + +def test_multi_segment_invalid_usage_raises() -> None: + """Prefix-view APIs and invalid segment configs raise for multi-segment + buffers.""" + with pytest.raises(ValueError): + ExtensibleTensor(max_num_bytes=100, device="cuda", num_segments=3) + + et = ExtensibleTensor(max_num_bytes=8192, device="cuda", num_segments=2) + try: + with pytest.raises(ValueError): + _ = et.tensor + with pytest.raises(ValueError): + et.resize_(256) + + et.resize_per_segment_(256) + with pytest.raises(ValueError): + et.resize_per_segment_(128) # shrink + with pytest.raises(ValueError): + et.resize_per_segment_(et.segment_capacity_bytes + 1) # over capacity + finally: + et.free() diff --git a/tests/v1/worker/test_extensible_kv_cache.py b/tests/v1/worker/test_extensible_kv_cache.py new file mode 100644 index 00000000000..d78ce79bd07 --- /dev/null +++ b/tests/v1/worker/test_extensible_kv_cache.py @@ -0,0 +1,369 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GPU integration tests for the extensible KV cache allocation paths. + +Drives `GPUModelRunner._allocate_kv_cache_tensors` / `_reshape_kv_cache_tensors` +/ `extend_kv_cache` directly with fake attention backends, covering the buffer +layouts the extensible flow supports: block-major (one committed prefix), +K/V-split (one prefix per half), Mamba (block-major per layer), and hybrid +attention + Mamba (attention re-strided to block-major). Buffer sizes exceed +the CUDA VMM allocation granularity so touching a block that the commit logic +missed would fault instead of silently passing. +""" + +from types import SimpleNamespace + +import pytest +import torch + +from vllm.v1.attention.backend import AttentionBackend +from vllm.v1.kv_cache_interface import ( + FullAttentionSpec, + KVCacheConfig, + KVCacheGroupSpec, + KVCacheTensor, + MambaSpec, +) +from vllm.v1.worker.gpu_model_runner import GPUModelRunner +from vllm.v1.worker.utils import AttentionGroup + +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA") + +BLOCK_SIZE = 16 +NUM_BLOCKS = 256 + + +class _SplitKVBackend(AttentionBackend): + """Fake backend with a K/V-split layout, like FlashAttention.""" + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + return (2, num_blocks, block_size, num_kv_heads, head_size) + + +class _BlockMajorBackend(AttentionBackend): + """Fake backend with a num-blocks-first layout, like FlashInfer.""" + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + return (num_blocks, 2, block_size, num_kv_heads, head_size) + + +class _StrideOrderBackend(AttentionBackend): + """Fake backend whose stride order makes a kv-first shape block-major.""" + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + return (2, num_blocks, block_size, num_kv_heads, head_size) + + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + assert not include_num_layers_dimension + return (1, 0, 2, 3, 4) + + +def _full_attention_spec() -> FullAttentionSpec: + # page_size_bytes = 2 (K+V) * 16 * 8 * 128 * 2 bytes = 64 KiB; 256 blocks + # = 16 MiB, several VMM granules per buffer. + return FullAttentionSpec( + block_size=BLOCK_SIZE, + num_kv_heads=8, + head_size=128, + dtype=torch.bfloat16, + ) + + +def _mamba_spec() -> MambaSpec: + # page_size_bytes = (8*128 + 16*64) * 4 bytes = 8 KiB per block per layer. + return MambaSpec( + block_size=BLOCK_SIZE, + shapes=((8, 128), (16, 64)), + dtypes=(torch.float32, torch.float32), + ) + + +def _make_runner(kv_cache_config: KVCacheConfig, attn_groups) -> GPUModelRunner: + runner = object.__new__(GPUModelRunner) + runner.device = torch.device("cuda:0") + runner.kv_cache_config = kv_cache_config + runner.attn_groups = attn_groups + runner.runner_only_attn_layers = set() + runner.cache_config = SimpleNamespace(cache_dtype="auto") + return runner + + +def _attention_config(spec: FullAttentionSpec, backend) -> tuple[KVCacheConfig, list]: + kv_cache_config = KVCacheConfig( + num_blocks=NUM_BLOCKS, + kv_cache_tensors=[ + KVCacheTensor(size=NUM_BLOCKS * spec.page_size_bytes, shared_by=["layer.0"]) + ], + kv_cache_groups=[KVCacheGroupSpec(layer_names=["layer.0"], kv_cache_spec=spec)], + ) + attn_groups = [ + [ + AttentionGroup( + backend=backend, + layer_names=["layer.0"], + kv_cache_spec=spec, + kv_cache_group_id=0, + ) + ] + ] + return kv_cache_config, attn_groups + + +def _free_buffers(runner: GPUModelRunner) -> None: + for buffer, _ in getattr(runner, "_extensible_kv_cache_buffers", []): + buffer.free() + + +def test_kv_cache_num_segments_by_layer() -> None: + """Segment counts follow the physical layout of each layer's backend.""" + spec = _full_attention_spec() + for backend, expected in ( + (_SplitKVBackend, 2), + (_BlockMajorBackend, 1), + # kv-first logical shape but block-major physical order -> 1 segment. + (_StrideOrderBackend, 1), + ): + kv_cache_config, attn_groups = _attention_config(spec, backend) + runner = _make_runner(kv_cache_config, attn_groups) + assert runner._kv_cache_num_segments_by_layer() == {"layer.0": expected} + + +def test_extensible_split_layout_grows_both_halves() -> None: + """A K/V-split layer keeps its natural layout and both halves grow in + lockstep.""" + spec = _full_attention_spec() + kv_cache_config, attn_groups = _attention_config(spec, _SplitKVBackend) + runner = _make_runner(kv_cache_config, attn_groups) + try: + raw_tensors = runner._allocate_kv_cache_tensors( + kv_cache_config, extensible=True + ) + kv_caches = runner._reshape_kv_cache_tensors(raw_tensors, [BLOCK_SIZE]) + kv_cache = kv_caches["layer.0"] + assert kv_cache.shape == (2, NUM_BLOCKS, BLOCK_SIZE, 8, 128) + [(buffer, bytes_per_block_per_segment)] = runner._extensible_kv_cache_buffers + assert buffer.num_segments == 2 + assert bytes_per_block_per_segment == spec.page_size_bytes // 2 + + # Only block 0 is committed -- in each half. + kv_cache[0, 0].fill_(1) # K, block 0 + kv_cache[1, 0].fill_(2) # V, block 0 + torch.cuda.synchronize() + + runner.extend_kv_cache(NUM_BLOCKS) + # Old data survives the grow; new blocks are usable in both halves and + # zeroed. + assert torch.all(kv_cache[0, 0] == 1) + assert torch.all(kv_cache[1, 0] == 2) + kv_cache[0, NUM_BLOCKS - 1].fill_(3) + kv_cache[1, NUM_BLOCKS - 1].fill_(4) + torch.cuda.synchronize() + assert torch.all(kv_cache[0, NUM_BLOCKS - 1] == 3) + assert torch.all(kv_cache[1, NUM_BLOCKS - 1] == 4) + assert torch.count_nonzero(kv_cache[:, 1 : NUM_BLOCKS - 1]) == 0 + finally: + _free_buffers(runner) + + +def test_extensible_block_major_layout() -> None: + """A layer whose physical layout is block-major uses a single segment.""" + spec = _full_attention_spec() + kv_cache_config, attn_groups = _attention_config(spec, _BlockMajorBackend) + runner = _make_runner(kv_cache_config, attn_groups) + try: + raw_tensors = runner._allocate_kv_cache_tensors( + kv_cache_config, extensible=True + ) + kv_caches = runner._reshape_kv_cache_tensors(raw_tensors, [BLOCK_SIZE]) + kv_cache = kv_caches["layer.0"] + assert kv_cache.shape == (NUM_BLOCKS, 2, BLOCK_SIZE, 8, 128) + [(buffer, bytes_per_block_per_segment)] = runner._extensible_kv_cache_buffers + assert buffer.num_segments == 1 + assert bytes_per_block_per_segment == spec.page_size_bytes + + kv_cache[0].fill_(1) + runner.extend_kv_cache(NUM_BLOCKS) + kv_cache[NUM_BLOCKS - 1].fill_(2) + torch.cuda.synchronize() + assert torch.all(kv_cache[0] == 1) + assert torch.all(kv_cache[NUM_BLOCKS - 1] == 2) + assert torch.count_nonzero(kv_cache[1 : NUM_BLOCKS - 1]) == 0 + finally: + _free_buffers(runner) + + +def test_legacy_split_layout_commits_everything() -> None: + """Without `extensible`, the full buffer is committed up front.""" + spec = _full_attention_spec() + kv_cache_config, attn_groups = _attention_config(spec, _SplitKVBackend) + runner = _make_runner(kv_cache_config, attn_groups) + raw_tensors = runner._allocate_kv_cache_tensors(kv_cache_config, extensible=False) + kv_caches = runner._reshape_kv_cache_tensors(raw_tensors, [BLOCK_SIZE]) + kv_cache = kv_caches["layer.0"] + kv_cache[0, NUM_BLOCKS - 1].fill_(1) + kv_cache[1, NUM_BLOCKS - 1].fill_(2) + torch.cuda.synchronize() + assert torch.all(kv_cache[0, NUM_BLOCKS - 1] == 1) + assert torch.all(kv_cache[1, NUM_BLOCKS - 1] == 2) + with pytest.raises(RuntimeError, match="extensible"): + runner.extend_kv_cache(NUM_BLOCKS) + + +def test_extensible_mamba_grows_per_layer() -> None: + """Mamba per-layer buffers are block-major and grow with the KV cache.""" + spec = _mamba_spec() + num_blocks = 512 + layer_names = ["mamba.0", "mamba.1"] + kv_cache_config = KVCacheConfig( + num_blocks=num_blocks, + kv_cache_tensors=[ + KVCacheTensor(size=num_blocks * spec.page_size_bytes, shared_by=[name]) + for name in layer_names + ], + kv_cache_groups=[KVCacheGroupSpec(layer_names=layer_names, kv_cache_spec=spec)], + ) + attn_groups = [ + [ + AttentionGroup( + backend=_BlockMajorBackend, + layer_names=layer_names, + kv_cache_spec=spec, + kv_cache_group_id=0, + ) + ] + ] + runner = _make_runner(kv_cache_config, attn_groups) + try: + raw_tensors = runner._allocate_kv_cache_tensors( + kv_cache_config, extensible=True + ) + kv_caches = runner._reshape_kv_cache_tensors(raw_tensors, [BLOCK_SIZE]) + assert set(kv_caches) == set(layer_names) + assert len(runner._extensible_kv_cache_buffers) == len(layer_names) + for buffer, bytes_per_block_per_segment in runner._extensible_kv_cache_buffers: + assert buffer.num_segments == 1 + assert bytes_per_block_per_segment == spec.page_size_bytes + + # Write block 0 of every state of every layer (the committed + # prefixes), then grow. + for name in layer_names: + for state_tensor in kv_caches[name]: + state_tensor[0].fill_(1) + torch.cuda.synchronize() + runner.extend_kv_cache(num_blocks) + for name in layer_names: + for state_tensor in kv_caches[name]: + state_tensor[num_blocks - 1].fill_(2) + torch.cuda.synchronize() + for name in layer_names: + for state_tensor in kv_caches[name]: + assert torch.all(state_tensor[0] == 1) + assert torch.all(state_tensor[num_blocks - 1] == 2) + assert torch.count_nonzero(state_tensor[1 : num_blocks - 1]) == 0 + finally: + _free_buffers(runner) + + +def test_extensible_hybrid_attention_mamba() -> None: + """In hybrid models the attention cache is re-strided to block-major, so + its buffer must use a single segment.""" + attn_spec = _full_attention_spec() + mamba_spec = _mamba_spec() + kv_cache_config = KVCacheConfig( + num_blocks=NUM_BLOCKS, + kv_cache_tensors=[ + KVCacheTensor( + size=NUM_BLOCKS * attn_spec.page_size_bytes, shared_by=["attn.0"] + ), + KVCacheTensor( + size=NUM_BLOCKS * mamba_spec.page_size_bytes, shared_by=["mamba.0"] + ), + ], + kv_cache_groups=[ + KVCacheGroupSpec(layer_names=["attn.0"], kv_cache_spec=attn_spec), + KVCacheGroupSpec(layer_names=["mamba.0"], kv_cache_spec=mamba_spec), + ], + ) + attn_groups = [ + [ + AttentionGroup( + backend=_SplitKVBackend, + layer_names=["attn.0"], + kv_cache_spec=attn_spec, + kv_cache_group_id=0, + ) + ], + [ + AttentionGroup( + backend=_BlockMajorBackend, + layer_names=["mamba.0"], + kv_cache_spec=mamba_spec, + kv_cache_group_id=1, + ) + ], + ] + runner = _make_runner(kv_cache_config, attn_groups) + try: + # The K/V-split attention layer is forced to one segment by the hybrid + # block-major re-stride. + assert runner._kv_cache_num_segments_by_layer() == {"attn.0": 1, "mamba.0": 1} + + raw_tensors = runner._allocate_kv_cache_tensors( + kv_cache_config, extensible=True + ) + kv_caches = runner._reshape_kv_cache_tensors( + raw_tensors, [BLOCK_SIZE, BLOCK_SIZE] + ) + attn_cache = kv_caches["attn.0"] + # `_update_hybrid_attention_mamba_layout` re-strides to interleave K/V + # per block: block b spans one contiguous page. + hidden_size = attn_cache.shape[2:].numel() + assert attn_cache.stride()[:2] == (hidden_size, 2 * hidden_size) + + attn_cache[0, 0].fill_(1) # K, block 0 + attn_cache[1, 0].fill_(2) # V, block 0 + for state_tensor in kv_caches["mamba.0"]: + state_tensor[0].fill_(3) + torch.cuda.synchronize() + + runner.extend_kv_cache(NUM_BLOCKS) + attn_cache[0, NUM_BLOCKS - 1].fill_(4) + attn_cache[1, NUM_BLOCKS - 1].fill_(5) + for state_tensor in kv_caches["mamba.0"]: + state_tensor[NUM_BLOCKS - 1].fill_(6) + torch.cuda.synchronize() + assert torch.all(attn_cache[0, 0] == 1) + assert torch.all(attn_cache[1, 0] == 2) + assert torch.all(attn_cache[0, NUM_BLOCKS - 1] == 4) + assert torch.all(attn_cache[1, NUM_BLOCKS - 1] == 5) + assert torch.count_nonzero(attn_cache[:, 1 : NUM_BLOCKS - 1]) == 0 + for state_tensor in kv_caches["mamba.0"]: + assert torch.all(state_tensor[0] == 3) + assert torch.all(state_tensor[NUM_BLOCKS - 1] == 6) + assert torch.count_nonzero(state_tensor[1 : NUM_BLOCKS - 1]) == 0 + finally: + _free_buffers(runner) diff --git a/vllm/config/cache.py b/vllm/config/cache.py index 208da979afd..3a8aa54f354 100644 --- a/vllm/config/cache.py +++ b/vllm/config/cache.py @@ -178,8 +178,9 @@ class CacheConfig: """Use CUDA virtual memory to reserve the KV cache address range before CUDA graph capture and commit the final size after capture. - This makes automatic KV sizing account for the actual CUDA graph pool. It - is only supported for CUDA, V1, and block-major attention backends. + This makes automatic KV sizing account for the actual CUDA graph pool. + Supported for all V1 CUDA attention backends (block-major and K/V-split + KV cache layouts) and for Mamba / linear-attention models. """ kv_offloading_size: float | None = None diff --git a/vllm/config/vllm.py b/vllm/config/vllm.py index eb13a379ff4..9c05befebb6 100644 --- a/vllm/config/vllm.py +++ b/vllm/config/vllm.py @@ -2112,6 +2112,9 @@ class VllmConfig: # Will be added by https://github.com/vllm-project/vllm/pull/35045 unsupported.append("KV sharing fast prefill") + if self.cache_config.enable_extensible_kv_cache: + unsupported.append("extensible KV cache") + if self.ec_transfer_config is not None: # Will be added by https://github.com/vllm-project/vllm/pull/38390 unsupported.append("EC transfer") diff --git a/vllm/entrypoints/llm.py b/vllm/entrypoints/llm.py index aa1a3398011..bb8514c9433 100644 --- a/vllm/entrypoints/llm.py +++ b/vllm/entrypoints/llm.py @@ -121,8 +121,9 @@ class LLM(BeamSearchOfflineMixin, PoolingOfflineMixin, OfflineInferenceMixin): gpu_memory_utilization enable_extensible_kv_cache: Use CUDA virtual memory to reserve the KV cache address range before CUDA graph capture and commit the final - cache size after capture. Only supported by V1 CUDA workers with - block-major attention backends. + cache size after capture. Supported by V1 CUDA workers for all + attention backends (block-major and K/V-split KV cache layouts) + and for Mamba / linear-attention models. cpu_offload_gb: The size (GiB) of CPU memory to use for offloading the model weights. This virtually increases the GPU memory space you can use to hold the model weights, at the cost of CPU-GPU data diff --git a/vllm/utils/extensible_tensor.py b/vllm/utils/extensible_tensor.py index 36327d87503..445fe0a14e5 100644 --- a/vllm/utils/extensible_tensor.py +++ b/vllm/utils/extensible_tensor.py @@ -177,7 +177,13 @@ def _round_up(value: int, multiple: int) -> int: class _VirtualBuffer: - """Own one device VA reservation and the physical chunks mapped into it.""" + """Own one device VA reservation and the physical chunks mapped into it. + + Physical memory is committed incrementally, at granularity-sized granules, + via `ensure_committed_range`; granules already mapped by an earlier + (possibly overlapping) range are skipped, so ranges may abut or overlap + freely. + """ def __init__(self, max_bytes: int, device_index: int) -> None: _ensure_context(device_index) @@ -201,29 +207,61 @@ class _VirtualBuffer: ) self.base_ptr: int = dptr.value - self.committed_bytes: int = 0 - self._handles: list[tuple[int, int]] = [] + # Granule indices (VA offset // granularity) that have physical + # memory mapped. + self._mapped_granules: set[int] = set() + # Each entry is (handle, va_offset, size) for one mapped physical chunk. + self._handles: list[tuple[int, int, int]] = [] self._freed: bool = False + @property + def committed_bytes(self) -> int: + """Total physically mapped bytes (a multiple of the granularity).""" + return len(self._mapped_granules) * self.granularity + def ensure_committed(self, nbytes: int) -> None: - if nbytes > self.reserved_size: + """Map physical pages so that at least the first `nbytes` are backed.""" + self.ensure_committed_range(0, nbytes) + + def ensure_committed_range(self, start: int, end: int) -> None: + """Map physical pages so that the byte range `[start, end)` is backed. + + The range is widened outward to granule boundaries; granules mapped by + earlier calls are skipped, so a granule shared by two requested ranges + is mapped once. + """ + if not 0 <= start <= end: + raise ValueError(f"Invalid range [{start}, {end}).") + if end > self.reserved_size: raise ValueError( - f"Requested {nbytes} bytes exceeds reserved capacity " + f"Requested range end {end} exceeds reserved capacity " f"{self.reserved_size}." ) - while self.committed_bytes < nbytes: - delta = _round_up(nbytes - self.committed_bytes, self.granularity) - chunk = min(delta, self.reserved_size - self.committed_bytes) - self._map_chunk(chunk) + if start == end: + return + first = start // self.granularity + last = (end + self.granularity - 1) // self.granularity # exclusive + run_start: int | None = None + for g in range(first, last + 1): + unmapped = g < last and g not in self._mapped_granules + if unmapped and run_start is None: + run_start = g + elif not unmapped and run_start is not None: + self._map_chunk_at( + run_start * self.granularity, (g - run_start) * self.granularity + ) + self._mapped_granules.update(range(run_start, g)) + run_start = None - def _map_chunk(self, size: int) -> None: + def _map_chunk_at(self, offset: int, size: int) -> None: + """Create one physical chunk of `size` bytes and map it at `offset`.""" _ensure_context(self.device_index) prop = _make_alloc_prop(self.device_index) handle = _CUmemHandle() _check(_cuda().cuMemCreate(ctypes.byref(handle), size, ctypes.byref(prop), 0)) - addr = self.base_ptr + self.committed_bytes + addr = self.base_ptr + offset try: _check(_cuda().cuMemMap(addr, size, 0, handle, 0)) except RuntimeError: @@ -236,8 +274,7 @@ class _VirtualBuffer: desc.flags = _CU_MEM_ACCESS_FLAGS_PROT_READWRITE _check(_cuda().cuMemSetAccess(addr, size, ctypes.byref(desc), 1)) - self._handles.append((handle.value, size)) - self.committed_bytes += size + self._handles.append((handle.value, offset, size)) def free(self) -> None: if self._freed: @@ -246,15 +283,13 @@ class _VirtualBuffer: _ensure_context(self.device_index) if self._handles: torch.cuda.synchronize(self.device_index) - offset = 0 - for handle, size in self._handles: + for handle, offset, size in self._handles: _check(_cuda().cuMemUnmap(self.base_ptr + offset, size)) _check(_cuda().cuMemRelease(handle)) - offset += size if self.base_ptr: _check(_cuda().cuMemAddressFree(self.base_ptr, self.reserved_size)) self._handles = [] - self.committed_bytes = 0 + self._mapped_granules = set() self.base_ptr = 0 def __del__(self) -> None: @@ -335,15 +370,33 @@ def _uint8_tensor_from_ptr(ptr: int, num_bytes: int, device_index: int) -> torch class ExtensibleTensor: - """A 1-D CUDA byte buffer that can grow without moving its base pointer.""" + """A 1-D CUDA byte buffer that can grow without moving its base pointer. + + With `num_segments > 1` the reservation is divided into that many equal + segments that grow in lockstep via `resize_per_segment_`: the committed + bytes form a prefix of each segment (segment `i` spans + `[i * segment_capacity_bytes, (i + 1) * segment_capacity_bytes)` of + `full_view()`). This backs layouts whose block dimension is not outermost, + e.g. a K/V-split KV cache (`num_segments=2`). `resize_` / `tensor` / + `append` assume a single contiguous prefix and are only valid when + `num_segments == 1`. + """ def __init__( self, max_num_bytes: int, device: torch.device | str | int | None = None, + num_segments: int = 1, ) -> None: if max_num_bytes < 0: raise ValueError("max_num_bytes must be non-negative.") + if num_segments < 1: + raise ValueError(f"num_segments must be positive, got {num_segments}.") + if max_num_bytes % num_segments != 0: + raise ValueError( + f"max_num_bytes ({max_num_bytes}) must be divisible by " + f"num_segments ({num_segments})." + ) if device is None: device = torch.cuda.current_device() @@ -357,14 +410,21 @@ class ExtensibleTensor: torch.cuda.init() self._max_num_bytes: int = max_num_bytes + self._num_segments: int = num_segments + self._segment_capacity_bytes: int = max_num_bytes // num_segments self._buffer: _VirtualBuffer = _VirtualBuffer(max_num_bytes, self._device_index) - self._num_bytes: int = 0 + self._bytes_per_segment: int = 0 @property def tensor(self) -> torch.Tensor: """Return a uint8 tensor view of the currently committed prefix.""" + if self._num_segments != 1: + raise ValueError( + "tensor (a single committed prefix) is only valid for " + "num_segments=1; use full_view() and index segments explicitly." + ) return _uint8_tensor_from_ptr( - self._buffer.base_ptr, self._num_bytes, self._device_index + self._buffer.base_ptr, self._bytes_per_segment, self._device_index ) def full_view(self) -> torch.Tensor: @@ -375,29 +435,73 @@ class ExtensibleTensor: def resize_(self, num_bytes: int) -> torch.Tensor: """Grow the buffer to `num_bytes` and return the committed-prefix view.""" - if num_bytes > self._max_num_bytes: + if self._num_segments != 1: raise ValueError( - f"Requested {num_bytes} bytes exceeds maximum size " - f"{self._max_num_bytes}." + "resize_ (a single committed prefix) is only valid for " + "num_segments=1; use resize_per_segment_." ) - if num_bytes < self._num_bytes: - raise ValueError( - f"ExtensibleTensor is grow-only: cannot resize from " - f"{self._num_bytes} to {num_bytes} bytes." - ) - self._buffer.ensure_committed(num_bytes) - self._num_bytes = num_bytes + self.resize_per_segment_(num_bytes) return self.tensor + def resize_per_segment_( + self, bytes_per_segment: int, zero_new: bool = False + ) -> None: + """Grow every segment's committed prefix to `bytes_per_segment` bytes. + + Existing bytes are preserved and the base pointer is unchanged. With + `zero_new=True` the newly committed byte range of each segment is + zeroed (bytes committed earlier are left intact). Raises if + `bytes_per_segment` is smaller than the current per-segment size + (shrink is unsupported) or larger than `segment_capacity_bytes`. + """ + old = self._bytes_per_segment + if bytes_per_segment < old: + raise ValueError( + f"ExtensibleTensor is grow-only: cannot resize from {old} " + f"to {bytes_per_segment} bytes per segment." + ) + if bytes_per_segment > self._segment_capacity_bytes: + raise ValueError( + f"Requested {bytes_per_segment} bytes per segment exceeds the " + f"segment capacity {self._segment_capacity_bytes}." + ) + if bytes_per_segment == old: + return + for i in range(self._num_segments): + start = i * self._segment_capacity_bytes + self._buffer.ensure_committed_range(start + old, start + bytes_per_segment) + self._bytes_per_segment = bytes_per_segment + if zero_new: + full = self.full_view() + for i in range(self._num_segments): + start = i * self._segment_capacity_bytes + full[start + old : start + bytes_per_segment].zero_() + def append(self, num_bytes: int) -> torch.Tensor: """Grow by `num_bytes` additional bytes and return the new view.""" if num_bytes < 0: raise ValueError("num_bytes to append must be non-negative.") - return self.resize_(self._num_bytes + num_bytes) + return self.resize_(self._bytes_per_segment + num_bytes) @property def num_bytes(self) -> int: - return self._num_bytes + """Current committed size in bytes, summed over all segments.""" + return self._bytes_per_segment * self._num_segments + + @property + def bytes_per_segment(self) -> int: + """Current committed prefix size of each segment in bytes.""" + return self._bytes_per_segment + + @property + def num_segments(self) -> int: + """Number of equal segments the reservation is divided into.""" + return self._num_segments + + @property + def segment_capacity_bytes(self) -> int: + """Maximum size of each segment (`max_num_bytes / num_segments`).""" + return self._segment_capacity_bytes @property def capacity_bytes(self) -> int: @@ -413,4 +517,4 @@ class ExtensibleTensor: def free(self) -> None: self._buffer.free() - self._num_bytes = 0 + self._bytes_per_segment = 0 diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py index 4a6ddf87c32..e4c06158c56 100644 --- a/vllm/v1/engine/core.py +++ b/vllm/v1/engine/core.py @@ -293,17 +293,17 @@ class EngineCore: has_kv_cache and vllm_config.cache_config.enable_extensible_kv_cache ) if use_extensible_kv_cache: + from vllm.platforms import current_platform + if vllm_config.cache_config.kv_cache_memory_bytes is not None: raise ValueError( "enable_extensible_kv_cache=True is not supported with " "kv_cache_memory_bytes. The extensible path requires " "automatic KV cache sizing." ) - if not all(self.model_executor.supports_extensible_kv_cache()): + if not current_platform.is_cuda(): raise ValueError( - "enable_extensible_kv_cache=True is only supported for " - "CUDA V1 attention backends with block-major KV cache " - "indexing." + "enable_extensible_kv_cache=True is only supported on CUDA." ) # Track max_model_len before KV cache config to detect auto-fit changes diff --git a/vllm/v1/executor/abstract.py b/vllm/v1/executor/abstract.py index 45cfecd64f0..3420d7427fb 100644 --- a/vllm/v1/executor/abstract.py +++ b/vllm/v1/executor/abstract.py @@ -158,9 +158,6 @@ class Executor(ABC): def get_kv_cache_specs(self) -> list[dict[str, KVCacheSpec]]: return self.collective_rpc("get_kv_cache_spec") - def supports_extensible_kv_cache(self) -> list[bool]: - return self.collective_rpc("supports_extensible_kv_cache") - def extend_kv_cache(self, num_blocks: int) -> None: self.collective_rpc("extend_kv_cache", args=(num_blocks,)) diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py index cd6316063a8..dd40455a72d 100644 --- a/vllm/v1/worker/gpu_model_runner.py +++ b/vllm/v1/worker/gpu_model_runner.py @@ -4,6 +4,7 @@ import functools import gc import itertools +import math import threading import time from collections import defaultdict @@ -7049,7 +7050,12 @@ class GPUModelRunner( to be reshaped to the desired shape before being used by the models. Args: - kv_cache_config: The KV cache config + kv_cache_config: The KV cache config; its `num_blocks` is the + declared capacity. + extensible: When True, reserve virtual address space for + `num_blocks` but commit only one block (per layout segment) + for CUDA graph capture; `extend_kv_cache` commits the rest + afterwards. When False, commit the full size up front. Returns: dict[str, torch.Tensor]: A map between layer names to their corresponding memory buffer for KV cache. @@ -7066,6 +7072,11 @@ class GPUModelRunner( "enable_extensible_kv_cache=True requires at least one KV block." ) + # One CUDA virtual-memory byte buffer per KV cache tensor. Each + # buffer keeps its layers' physical layout and is committed as one + # prefix per layout segment (e.g. the K and V halves of a + # K/V-split layout) -- see `ExtensibleTensor`. + num_segments_by_layer = self._kv_cache_num_segments_by_layer() self._extensible_kv_cache_buffers: list[tuple[ExtensibleTensor, int]] = [] self._extensible_kv_cache_committed_blocks = 1 self._extensible_kv_cache_enabled = True @@ -7074,12 +7085,27 @@ class GPUModelRunner( assert bytes_per_block * kv_cache_config.num_blocks == ( kv_cache_tensor.size ) + segment_counts = { + num_segments_by_layer[layer_name] + for layer_name in kv_cache_tensor.shared_by + if layer_name in num_segments_by_layer + } + assert len(segment_counts) == 1, ( + "Layers sharing one KV cache tensor disagree on the buffer " + f"segmentation ({segment_counts}): {kv_cache_tensor.shared_by}" + ) + num_segments = segment_counts.pop() + assert bytes_per_block % num_segments == 0 + bytes_per_block_per_segment = bytes_per_block // num_segments buffer = ExtensibleTensor( max_num_bytes=kv_cache_tensor.size, device=self.device, + num_segments=num_segments, ) - self._extensible_kv_cache_buffers.append((buffer, bytes_per_block)) - buffer.resize_(bytes_per_block).zero_() + self._extensible_kv_cache_buffers.append( + (buffer, bytes_per_block_per_segment) + ) + buffer.resize_per_segment_(bytes_per_block_per_segment, zero_new=True) tensor = buffer.full_view() for layer_name in kv_cache_tensor.shared_by: kv_cache_raw_tensors[layer_name] = tensor @@ -7125,6 +7151,49 @@ class GPUModelRunner( ) return kv_cache_raw_tensors + def _kv_cache_num_segments_by_layer(self) -> dict[str, int]: + """Number of equal contiguous segments of each layer's KV cache buffer + under its physical layout -- i.e. the product of the physical dims + preceding the block dim. Within each segment, block `b` occupies bytes + `[b * S, (b + 1) * S)` where `S = bytes_per_block / num_segments`, so + the extensible KV cache can commit a per-segment prefix of blocks. + """ + has_mamba = self.kv_cache_config.has_mamba_layers + num_segments_by_layer: dict[str, int] = {} + for group in self._kv_cache_spec_attn_group_iterator(): + kv_cache_spec = group.kv_cache_spec + if isinstance(kv_cache_spec, AttentionSpec) and not has_mamba: + attn_backend = group.backend + block_dim = attn_backend.get_kv_cache_block_dim( + kv_cache_spec.block_size, + kv_cache_spec.num_kv_heads, + kv_cache_spec.head_size, + cache_dtype_str=self.cache_config.cache_dtype, + ) + kv_cache_shape = attn_backend.get_kv_cache_shape( + 1, + kv_cache_spec.block_size, + kv_cache_spec.num_kv_heads, + kv_cache_spec.head_size, + cache_dtype_str=self.cache_config.cache_dtype, + ) + try: + stride_order = attn_backend.get_kv_cache_stride_order() + except (AttributeError, NotImplementedError): + stride_order = tuple(range(len(kv_cache_shape))) + num_segments = math.prod( + kv_cache_shape[dim] + for dim in stride_order[: stride_order.index(block_dim)] + ) + else: + # Mamba states are packed per block (block-major), and + # `_update_hybrid_attention_mamba_layout` re-strides attention + # caches of hybrid models to a block-major interleaved layout. + num_segments = 1 + for layer_name in group.layer_names: + num_segments_by_layer[layer_name] = num_segments + return num_segments_by_layer + def _attn_group_iterator(self) -> Iterator[AttentionGroup]: return itertools.chain.from_iterable(self.attn_groups) @@ -7279,39 +7348,26 @@ class GPUModelRunner( stride=(hidden_size, 2 * hidden_size, *kv_cache.stride()[2:]), ) - def supports_extensible_kv_cache(self) -> bool: - if not current_platform.is_cuda(): - return False - - has_kv_cache = False - layer_type = cast(type[Any], AttentionLayerBase) - attn_layers = get_layers_from_vllm_config(self.vllm_config, layer_type) - for attn_module in attn_layers.values(): - if getattr(attn_module, "kv_sharing_target_layer_name", None): - continue - kv_cache_spec = attn_module.get_kv_cache_spec(self.vllm_config) - if kv_cache_spec is None: - continue - if not isinstance(kv_cache_spec, AttentionSpec): - return False - attn_backend = attn_module.get_attn_backend() - with set_current_vllm_config(self.vllm_config): - if not attn_backend.indexes_kv_by_block_stride(): - return False - has_kv_cache = True - return has_kv_cache - def extend_kv_cache(self, num_blocks: int) -> None: + """Commit physical pages so the KV cache holds `num_blocks` blocks. + + Grows the KV cache after CUDA graph capture, once the available memory + is known. No re-view is needed: the layers already view the full + capacity and each block stays at a fixed offset within its layout + segment, so captured graphs stay valid as more pages are mapped under + the stable base pointer. Newly committed blocks are zeroed. + """ if not getattr(self, "_extensible_kv_cache_enabled", False): raise RuntimeError("extend_kv_cache requires an extensible KV cache.") if num_blocks <= self._extensible_kv_cache_committed_blocks: return - for buffer, bytes_per_block in self._extensible_kv_cache_buffers: - old_num_bytes = buffer.num_bytes - new_num_bytes = num_blocks * bytes_per_block - committed_view = buffer.resize_(new_num_bytes) - committed_view[old_num_bytes:new_num_bytes].zero_() + for buffer, bytes_per_block_per_segment in self._extensible_kv_cache_buffers: + # Zero only the freshly committed blocks; existing ones are left + # intact. + buffer.resize_per_segment_( + num_blocks * bytes_per_block_per_segment, zero_new=True + ) self._extensible_kv_cache_committed_blocks = num_blocks logger.info("Extended KV cache to %d blocks.", num_blocks) diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py index 78afba16c63..ef16c9e8593 100644 --- a/vllm/v1/worker/gpu_worker.py +++ b/vllm/v1/worker/gpu_worker.py @@ -712,10 +712,12 @@ class Worker(WorkerBase): ensure_kv_transfer_initialized(self.vllm_config, kv_cache_config) with self._maybe_get_memory_pool_context(tag="kv_cache"): - self.model_runner.initialize_kv_cache( - kv_cache_config, - extensible=extensible, - ) + if extensible: + # Only the V1 GPU model runner implements the extensible flow; + # keep the default call signature untouched otherwise. + self.model_runner.initialize_kv_cache(kv_cache_config, extensible=True) + else: + self.model_runner.initialize_kv_cache(kv_cache_config) if self.model_config.enable_return_routed_experts: self.model_runner.init_routed_experts_capturer() @@ -728,10 +730,6 @@ class Worker(WorkerBase): ): self.model_runner._init_kv_zero_meta() - def supports_extensible_kv_cache(self) -> bool: - supports_fn = getattr(self.model_runner, "supports_extensible_kv_cache", None) - return bool(supports_fn is not None and supports_fn()) - def extend_kv_cache(self, num_blocks: int) -> None: self.cache_config.num_gpu_blocks = num_blocks self.model_runner.extend_kv_cache(num_blocks) diff --git a/vllm/v1/worker/worker_base.py b/vllm/v1/worker/worker_base.py index 13c74e777e2..2c18732f547 100644 --- a/vllm/v1/worker/worker_base.py +++ b/vllm/v1/worker/worker_base.py @@ -100,9 +100,6 @@ class WorkerBase: """Get specifications for KV cache implementation.""" raise NotImplementedError - def supports_extensible_kv_cache(self) -> bool: - return False - def extend_kv_cache(self, num_blocks: int) -> None: raise RuntimeError( f"{self.__class__.__name__} does not support extensible KV cache."