Signed-off-by: yancaocn <yancaochn@163.com> Co-authored-by: yancaocn <yancaochn@163.com> Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
198 lines
7.1 KiB
Python
198 lines
7.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Regression tests for the cross-attention KV cache liveness guard.
|
|
|
|
These tests pin the fix for cross-prompt contamination. The cache is keyed
|
|
by a *storage fingerprint* of the source tensor (``data_ptr`` / shape /
|
|
stride / dtype / device / ``_version``). That fingerprint is **not** unique
|
|
across requests: once the source tensor is freed, PyTorch's caching
|
|
allocator may hand its address to an unrelated tensor of the same shape,
|
|
producing a false cache hit -- request B would silently reuse request A's
|
|
projected cross-attention K/V.
|
|
|
|
``SourceTensorLRUCache`` stores a :mod:`weakref` to the source tensor next
|
|
to the cached value and verifies the source is still alive on every lookup.
|
|
An address reused by a *different* tensor still matches the fingerprint,
|
|
but the original source's weakref is dead by then, so the lookup becomes a
|
|
miss instead of a silent wrong hit.
|
|
|
|
The address-reuse scenario is reproduced deterministically with two views
|
|
over the same storage: the second view's fingerprint is identical to the
|
|
first's (same ``data_ptr`` / shape / dtype), but the first view's weakref
|
|
is dead once the view object is freed, so the second lookup must miss.
|
|
"""
|
|
|
|
import gc
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm_omni.diffusion.models.helios.cross_attn_cache import SourceTensorLRUCache
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Intra-request reuse (the cache's intended purpose)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIntraRequestReuse:
|
|
"""Within one request the source tensor is alive across denoise steps."""
|
|
|
|
def test_same_source_hits(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a = torch.randn(4, 8)
|
|
cache.put(a, "A")
|
|
assert cache.get(a) == "A"
|
|
|
|
def test_repeated_get_keeps_hitting(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a = torch.randn(4, 8)
|
|
cache.put(a, "A")
|
|
for _ in range(50):
|
|
assert cache.get(a) == "A"
|
|
|
|
def test_distinct_live_sources_do_not_collide(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a = torch.randn(4, 8)
|
|
b = torch.randn(4, 8)
|
|
assert a.data_ptr() != b.data_ptr()
|
|
cache.put(a, "A")
|
|
# b has a different data_ptr -> different key -> miss
|
|
assert cache.get(b) is None
|
|
assert cache.get(a) == "A"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The bug: an address reused after the source is freed must NOT false-hit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAddressReuseAfterFree:
|
|
"""The contamination bug: freed source's address reused by a new tensor."""
|
|
|
|
def test_freed_source_reused_address_does_not_false_hit(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
storage = torch.empty(4, 8)
|
|
a = storage[:] # view; a.data_ptr() == storage.data_ptr()
|
|
cache.put(a, "A")
|
|
assert cache.get(a) == "A"
|
|
ptr_a = a.data_ptr()
|
|
del a
|
|
gc.collect()
|
|
# A new view over the SAME storage -> identical fingerprint
|
|
# (data_ptr / shape / stride / dtype / device / version=0).
|
|
b = storage[:]
|
|
assert b.data_ptr() == ptr_a
|
|
# Without the weakref guard this would falsely return "A"
|
|
# (cross-prompt contamination). With the guard the dead weakref
|
|
# turns the fingerprint match into a miss.
|
|
assert cache.get(b) is None
|
|
assert len(cache) == 0 # stale entry evicted on miss
|
|
|
|
def test_recompute_after_miss_stores_new_source(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
storage = torch.empty(4, 8)
|
|
a = storage[:]
|
|
cache.put(a, "A")
|
|
del a
|
|
gc.collect()
|
|
b = storage[:] # reuses address
|
|
assert cache.get(b) is None
|
|
cache.put(b, "B") # the recompute path in the transformer
|
|
assert cache.get(b) == "B"
|
|
assert len(cache) == 1
|
|
|
|
def test_two_consecutive_requests_no_contamination(self):
|
|
# End-to-end simulation of the serving sequence that triggered the
|
|
# bug: request A populates the cache, A is freed, request B reuses
|
|
# A's address. B must never observe A's cached value.
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
storage = torch.empty(2, 16)
|
|
|
|
a = storage[:]
|
|
cache.put(a, "A_projected_kv")
|
|
del a
|
|
gc.collect()
|
|
|
|
b = storage[:]
|
|
assert cache.get(b) is None # B does not inherit A's projection
|
|
cache.put(b, "B_projected_kv")
|
|
assert cache.get(b) == "B_projected_kv"
|
|
|
|
def test_freed_then_distinct_address_misses(self):
|
|
# When the new tensor gets a DIFFERENT address, the key differs and
|
|
# it trivially misses. The weakref guard must not break this path.
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a = torch.randn(4, 8)
|
|
cache.put(a, "A")
|
|
del a
|
|
gc.collect()
|
|
b = torch.randn(4, 8)
|
|
assert cache.get(b) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LRU semantics (unchanged by the fix, but must still hold)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLRUEviction:
|
|
def test_lru_evicts_oldest(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a, b, c = (torch.randn(1) for _ in range(3))
|
|
cache.put(a, "A")
|
|
cache.put(b, "B")
|
|
cache.put(c, "C")
|
|
assert cache.get(a) is None # evicted
|
|
assert cache.get(b) == "B"
|
|
assert cache.get(c) == "C"
|
|
|
|
def test_get_renews_recency(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a, b, c = (torch.randn(1) for _ in range(3))
|
|
cache.put(a, "A")
|
|
cache.put(b, "B")
|
|
assert cache.get(a) == "A" # a is now most-recent
|
|
cache.put(c, "C") # evicts b (oldest)
|
|
assert cache.get(b) is None
|
|
assert cache.get(a) == "A"
|
|
assert cache.get(c) == "C"
|
|
|
|
|
|
class TestPutOverwrite:
|
|
def test_same_source_overwrites_value(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a = torch.randn(4, 8)
|
|
cache.put(a, "A1")
|
|
cache.put(a, "A2")
|
|
assert cache.get(a) == "A2"
|
|
assert len(cache) == 1
|
|
|
|
|
|
class TestClear:
|
|
def test_clear_empties_cache(self):
|
|
cache = SourceTensorLRUCache(max_size=2)
|
|
a, b = torch.randn(1), torch.randn(1)
|
|
cache.put(a, "A")
|
|
cache.put(b, "B")
|
|
assert len(cache) == 2
|
|
cache.clear()
|
|
assert len(cache) == 0
|
|
assert cache.get(a) is None
|
|
assert cache.get(b) is None
|
|
|
|
|
|
class TestProactiveStaleEviction:
|
|
def test_put_evicts_dead_entries(self):
|
|
cache = SourceTensorLRUCache(max_size=4)
|
|
a = torch.randn(4, 8)
|
|
cache.put(a, "A")
|
|
del a
|
|
gc.collect()
|
|
b = torch.randn(4, 8)
|
|
cache.put(b, "B") # proactively drops the dead "a" entry
|
|
assert len(cache) == 1
|
|
assert cache.get(b) == "B"
|