From a2be4d3cc7b978a6720dab32d28ae310775acb16 Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:11:46 +0800 Subject: [PATCH] [Bugfix][Qwen4Exp] Release the profiling KV cache held by QSA key views (#58961) Signed-off-by: Zihua Wu <13583761+lucifer1004@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- tests/models/qwen4_exp/test_qsa_reference.py | 23 +++++++++++++++++++- vllm/models/qwen4_exp/common/qsa_cache.py | 21 ++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/tests/models/qwen4_exp/test_qsa_reference.py b/tests/models/qwen4_exp/test_qsa_reference.py index b3bc0d1cae..a6f3b6557d 100644 --- a/tests/models/qwen4_exp/test_qsa_reference.py +++ b/tests/models/qwen4_exp/test_qsa_reference.py @@ -17,6 +17,7 @@ from vllm.models.qwen4_exp.nvidia.ops import qsa as qsa_ops from vllm.models.qwen4_exp.nvidia.ops import qsa_indexer as qsa_indexer_ops from vllm.platforms import current_platform from vllm.triton_utils import HAS_TRITON +from vllm.v1.worker.utils import clear_layer_kv_caches requires_qsa_kernels = pytest.mark.skipif( not current_platform.is_cuda() or not HAS_TRITON, @@ -365,7 +366,9 @@ def test_qsa_circular_buffer_survives_one_speculative_step(chunk_start: int) -> assert set(slots.tolist()).isdisjoint((committed % capacity).tolist()) -def _qsa_key_cache(block_size: int, compress_ratio: int) -> qsa_cache.QSAKeyStateCache: +def _qsa_key_cache( + block_size: int, compress_ratio: int, **kwargs +) -> qsa_cache.QSAKeyStateCache: return qsa_cache.QSAKeyStateCache( head_size=64, dtype=torch.bfloat16, @@ -375,6 +378,7 @@ def _qsa_key_cache(block_size: int, compress_ratio: int) -> qsa_cache.QSAKeyStat compilation_config=SimpleNamespace(static_forward_context={}) ), compress_ratio=compress_ratio, + **kwargs, ) @@ -402,6 +406,23 @@ def test_qsa_state_caches_adapt_the_unified_logical_layout() -> None: assert compressed_cache.kv_cache.data_ptr() == compressed_view.data_ptr() +def test_clearing_qsa_key_cache_releases_its_storage() -> None: + """Profiling teardown clears `kv_cache`; no derived view may keep it alive.""" + raw_cache = _qsa_key_cache( + block_size=32, compress_ratio=4, cache_rope_positions=True + ) + kv = torch.zeros(2, 1, 8, raw_cache.head_size, dtype=torch.bfloat16) + references_before_bind = torch._C._storage_Use_Count(kv.untyped_storage()._cdata) + raw_cache.bind_kv_cache(kv) + assert raw_cache.key_cache.shape[-1] == 64 + assert raw_cache.rope_position_cache.dtype == torch.int64 + + clear_layer_kv_caches([raw_cache]) + + references = torch._C._storage_Use_Count(kv.untyped_storage()._cdata) + assert references == references_before_bind + + @pytest.mark.parametrize( ("compress_ratio", "num_spec", "expected"), [(4, 0, 4), (4, 1, 8), (4, 3, 8), (4, 4, 8), (4, 5, 12), (2, 3, 6)], diff --git a/vllm/models/qwen4_exp/common/qsa_cache.py b/vllm/models/qwen4_exp/common/qsa_cache.py index eb0758025f..13228185ec 100644 --- a/vllm/models/qwen4_exp/common/qsa_cache.py +++ b/vllm/models/qwen4_exp/common/qsa_cache.py @@ -825,15 +825,18 @@ class QSAKeyStateCache(_QSAStateCache): ) super().__init__(head_size=storage_head_size, **kwargs) - def bind_kv_cache(self, kv_cache: torch.Tensor) -> None: - super().bind_kv_cache(kv_cache) - qsa_cache = self.kv_cache - self.key_cache = qsa_cache[..., : self.key_head_size] - if self.cache_rope_positions: - position_tail = qsa_cache[..., self.rope_position_offset :] - self.rope_position_cache = position_tail.view(torch.int64) - else: - self.rope_position_cache = None + # Derived on access so `kv_cache` stays the only reference to the bound + # storage: clearing it (e.g. after CUDA graph memory profiling) must free + # the cache, or the freed block stays pinned in the allocator. + @property + def key_cache(self) -> torch.Tensor: + return self.kv_cache[..., : self.key_head_size] + + @property + def rope_position_cache(self) -> torch.Tensor | None: + if not self.cache_rope_positions: + return None + return self.kv_cache[..., self.rope_position_offset :].view(torch.int64) def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec: # Hold the open group's committed keys plus every row a speculative