forked from Karylab-cklius/vllm
Port of internal D110967544. The extensible KV cache flow (reserve KV virtual address space up front, capture CUDA graphs first, then size and commit the KV cache from post-capture free memory) previously required a block-major attention backend and rejected Mamba models. This enables it for every backend layout and for Mamba / linear attention: - ExtensibleTensor gains num_segments: the reservation is divided into equal segments that grow in lockstep, with committed bytes forming a prefix of each segment. Physical pages are mapped at allocation-granularity granules and deduped across overlapping ranges, so a granule straddling a segment boundary is mapped exactly once. resize_per_segment_(bytes, zero_new=True) zeroes only the newly committed logical range of each segment. - Each KV cache buffer keeps its layers' physical layout and is committed as one prefix per layout segment. The segment count is derived from the backend's get_kv_cache_shape / get_kv_cache_block_dim / stride order: K/V-split layouts (e.g. FlashAttention) get one prefix per half, block-major layouts (e.g. FlashInfer, MLA) a single prefix. Mamba state pages are block-major per layer, and hybrid-model attention caches are re-strided to block-major, so both use a single segment. - Removed the supports_extensible_kv_cache gate plumbing from EngineCore, Executor, Worker, WorkerBase and GPUModelRunner; a CUDA platform check remains in EngineCore. - enable_extensible_kv_cache is reported as unsupported by the V2 model runner so V2-default models fall back to the V1 runner (which implements the flow); also fixed initialize_kv_cache being called with the extensible kwarg on runners that do not accept it, which broke every default V2-runner boot on this branch. Tested on H100: - tests/utils_/test_extensible_tensor.py (5 passed, incl. new segmented lockstep-grow/zero, granule-dedup and invalid-usage tests) - tests/v1/worker/test_extensible_kv_cache.py (new, 6 passed: segment derivation, split grows both halves, block-major, legacy full commit, Mamba per-layer growth, hybrid attention+Mamba) - E2E Qwen3-0.6B greedy with VLLM_ATTENTION_BACKEND=FLASH_ATTN (a K/V-split backend the old gate rejected): extensible generations byte-identical to the legacy path; log shows reserve then "Extended KV cache to 34663 blocks". V2->V1 auto-fallback path verified as well.
142 lines
5.0 KiB
Python
142 lines
5.0 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm.utils.extensible_tensor import ExtensibleTensor
|
|
|
|
pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
|
|
|
|
|
|
def test_extensible_tensor_grows_without_moving() -> None:
|
|
buffer = ExtensibleTensor(4096, device="cuda")
|
|
try:
|
|
base_ptr = buffer.base_ptr
|
|
first_view = buffer.resize_(1024)
|
|
assert first_view.data_ptr() == base_ptr
|
|
first_view.fill_(7)
|
|
|
|
second_view = buffer.resize_(2048)
|
|
assert second_view.data_ptr() == base_ptr
|
|
assert torch.equal(second_view[:1024], torch.full_like(second_view[:1024], 7))
|
|
|
|
second_view[1024:].fill_(3)
|
|
assert torch.equal(buffer.tensor, second_view)
|
|
|
|
full_view = buffer.full_view()
|
|
assert full_view.data_ptr() == base_ptr
|
|
assert full_view.numel() == 4096
|
|
finally:
|
|
buffer.free()
|
|
|
|
|
|
def test_extensible_tensor_rejects_shrink_and_overflow() -> None:
|
|
buffer = ExtensibleTensor(1024, device="cuda")
|
|
try:
|
|
buffer.resize_(512)
|
|
with pytest.raises(ValueError, match="grow-only"):
|
|
buffer.resize_(256)
|
|
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()
|