| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | from unittest.mock import patch |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from vllm.v1.core.kv_cache_metrics import ( |
| 9 | BlockMetricsState, |
| 10 | KVCacheMetricsCollector, |
| 11 | ) |
| 12 | from vllm.v1.core.kv_cache_utils import KVCacheBlock |
| 13 | |
| 14 | |
| 15 | class TestBlockMetricsState: |
| 16 | def test_init(self): |
| 17 | with patch("time.monotonic_ns", return_value=1000000000): |
| 18 | state = BlockMetricsState() |
| 19 | assert state.birth_time_ns == 1000000000 |
| 20 | assert state.last_access_ns == 1000000000 |
| 21 | assert len(state.access_history) == 0 |
| 22 | |
| 23 | def test_access_tracking(self): |
| 24 | with patch("time.monotonic_ns", return_value=1000000000): |
| 25 | state = BlockMetricsState() |
| 26 | |
| 27 | with patch("time.monotonic_ns", return_value=2000000000): |
| 28 | state.record_access() |
| 29 | |
| 30 | assert state.last_access_ns == 2000000000 |
| 31 | assert list(state.access_history) == [2000000000] |
| 32 | |
| 33 | def test_ring_buffer_wraps_at_4(self): |
| 34 | with patch("time.monotonic_ns", return_value=1000000000): |
| 35 | state = BlockMetricsState() |
| 36 | |
| 37 | for i in range(5): |
| 38 | t = 1000000000 + (i + 1) * 1000000000 |
| 39 | with patch("time.monotonic_ns", return_value=t): |
| 40 | state.record_access() |
| 41 | |
| 42 | assert len(state.access_history) == 4 |
| 43 | assert list(state.access_history) == [ |
| 44 | 3000000000, |
| 45 | 4000000000, |
| 46 | 5000000000, |
| 47 | 6000000000, |
| 48 | ] |
| 49 | |
| 50 | def test_lifetime(self): |
| 51 | with patch("time.monotonic_ns", return_value=1000000000): |
| 52 | state = BlockMetricsState() |
| 53 | with patch("time.monotonic_ns", return_value=6500000000): |
| 54 | assert abs(state.get_lifetime_seconds() - 5.5) < 0.001 |
| 55 | |
| 56 | def test_idle_time(self): |
| 57 | with patch("time.monotonic_ns", return_value=1000000000): |
| 58 | state = BlockMetricsState() |
| 59 | state.last_access_ns = 2000000000 |
| 60 | with patch("time.monotonic_ns", return_value=5200000000): |
| 61 | assert abs(state.get_idle_time_seconds() - 3.2) < 0.001 |
| 62 | |
| 63 | def test_reuse_gaps(self): |
| 64 | with patch("time.monotonic_ns", return_value=1000000000): |
| 65 | state = BlockMetricsState() |
| 66 | |
| 67 | base = 1000000000 |
| 68 | for offset in [0, 1.5, 3.0, 5.5]: |
| 69 | state.access_history.append(base + int(offset * 1e9)) |
| 70 | |
| 71 | gaps = state.get_reuse_gaps_seconds() |
| 72 | assert len(gaps) == 3 |
| 73 | assert gaps[0] == 1.5 and gaps[1] == 1.5 and gaps[2] == 2.5 |
| 74 | |
| 75 | def test_ring_wrap_only_gives_3_gaps(self): |
| 76 | # 5 accesses in size-4 buffer = 3 gaps |
| 77 | with patch("time.monotonic_ns", return_value=1000000000): |
| 78 | state = BlockMetricsState() |
| 79 | |
| 80 | for i in range(5): |
| 81 | state.access_history.append(1000000000 + i * 1000000000) |
| 82 | |
| 83 | assert len(state.get_reuse_gaps_seconds()) == 3 |
| 84 | |
| 85 | |
| 86 | class TestKVCacheMetricsCollector: |
| 87 | def test_sample_rate_validation(self): |
| 88 | with pytest.raises(AssertionError): |
| 89 | KVCacheMetricsCollector(sample_rate=-0.1) |
| 90 | with pytest.raises(AssertionError): |
| 91 | KVCacheMetricsCollector(sample_rate=1.5) |
| 92 | with pytest.raises(AssertionError): |
| 93 | KVCacheMetricsCollector(sample_rate=0.0) |
| 94 | |
| 95 | def test_sampling(self): |
| 96 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 97 | assert sum(1 for _ in range(100) if c.should_sample_block()) == 100 |
| 98 | |
| 99 | c = KVCacheMetricsCollector(sample_rate=0.5) |
| 100 | samples = sum(1 for _ in range(1000) if c.should_sample_block()) |
| 101 | assert 400 < samples < 600 |
| 102 | |
| 103 | def test_alloc(self): |
| 104 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 105 | |
| 106 | blocks = [KVCacheBlock(block_id=i) for i in range(5)] |
| 107 | with patch("time.monotonic_ns", return_value=1000000000): |
| 108 | for block in blocks: |
| 109 | c.on_block_allocated(block) |
| 110 | |
| 111 | assert len(c.block_metrics) == 5 |
| 112 | |
| 113 | def test_access(self): |
| 114 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 115 | block = KVCacheBlock(block_id=0) |
| 116 | |
| 117 | with patch("time.monotonic_ns", return_value=1000000000): |
| 118 | c.on_block_allocated(block) |
| 119 | |
| 120 | for i in range(3): |
| 121 | t = 1000000000 + (i + 1) * 1000000000 |
| 122 | with patch("time.monotonic_ns", return_value=t): |
| 123 | c.on_block_accessed(block) |
| 124 | |
| 125 | assert len(c.block_metrics[0].access_history) == 3 |
| 126 | |
| 127 | def test_evict_no_accesses(self): |
| 128 | # lifetime should equal idle if never accessed |
| 129 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 130 | |
| 131 | block = KVCacheBlock(block_id=0) |
| 132 | with patch("time.monotonic_ns", return_value=1000000000): |
| 133 | c.on_block_allocated(block) |
| 134 | |
| 135 | with patch("time.monotonic_ns", return_value=6000000000): |
| 136 | c.on_block_evicted(block) |
| 137 | |
| 138 | events = c.drain_events() |
| 139 | assert len(events) == 1 |
| 140 | assert abs(events[0].lifetime_seconds - 5.0) < 0.001 |
| 141 | assert abs(events[0].idle_seconds - 5.0) < 0.001 |
| 142 | |
| 143 | def test_evict(self): |
| 144 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 145 | |
| 146 | block = KVCacheBlock(block_id=0) |
| 147 | with patch("time.monotonic_ns", return_value=1000000000): |
| 148 | c.on_block_allocated(block) |
| 149 | |
| 150 | with patch("time.monotonic_ns", return_value=2000000000): |
| 151 | c.on_block_accessed(block) |
| 152 | with patch("time.monotonic_ns", return_value=3000000000): |
| 153 | c.on_block_accessed(block) |
| 154 | |
| 155 | with patch("time.monotonic_ns", return_value=4000000000): |
| 156 | c.on_block_evicted(block) |
| 157 | |
| 158 | events = c.drain_events() |
| 159 | assert len(events) == 1 |
| 160 | sample = events[0] |
| 161 | assert abs(sample.lifetime_seconds - 3.0) < 0.001 |
| 162 | assert abs(sample.idle_seconds - 1.0) < 0.001 |
| 163 | assert sample.reuse_gaps_seconds == (1.0,) |
| 164 | assert 0 not in c.block_metrics |
| 165 | |
| 166 | def test_reset(self): |
| 167 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 168 | |
| 169 | with patch("time.monotonic_ns", return_value=1000000000): |
| 170 | for i in range(5): |
| 171 | c.on_block_allocated(KVCacheBlock(block_id=i)) |
| 172 | |
| 173 | assert len(c.block_metrics) == 5 |
| 174 | c.reset() |
| 175 | assert len(c.block_metrics) == 0 |
| 176 | |
| 177 | with patch("time.monotonic_ns", return_value=2000000000): |
| 178 | c.on_block_allocated(KVCacheBlock(block_id=10)) |
| 179 | assert 10 in c.block_metrics |
| 180 | |
| 181 | def test_huge_time_jump(self): |
| 182 | c = KVCacheMetricsCollector(sample_rate=1.0) |
| 183 | |
| 184 | block = KVCacheBlock(block_id=0) |
| 185 | with patch("time.monotonic_ns", return_value=1000000000): |
| 186 | c.on_block_allocated(block) |
| 187 | |
| 188 | with patch("time.monotonic_ns", return_value=9999999999999999): |
| 189 | c.on_block_evicted(block) |
| 190 | |
| 191 | events = c.drain_events() |
| 192 | assert len(events) == 1 |
| 193 | assert events[0].lifetime_seconds > 0 |
| 194 | |
| 195 | |
| 196 | def test_kv_cache_metrics_collector_smoke() -> None: |
| 197 | """Simple smoke test for KVCacheMetricsCollector on CPU.""" |
| 198 | collector = KVCacheMetricsCollector(sample_rate=1.0) |
| 199 | block = KVCacheBlock(block_id=123) |
| 200 | |
| 201 | # Allocate at t = 1.0s. |
| 202 | with patch("time.monotonic_ns", return_value=1_000_000_000): |
| 203 | collector.on_block_allocated(block) |
| 204 | |
| 205 | # Access at t = 2.0s and t = 3.0s. |
| 206 | with patch("time.monotonic_ns", return_value=2_000_000_000): |
| 207 | collector.on_block_accessed(block) |
| 208 | with patch("time.monotonic_ns", return_value=3_000_000_000): |
| 209 | collector.on_block_accessed(block) |
| 210 | |
| 211 | # Evict at t = 4.0s. |
| 212 | with patch("time.monotonic_ns", return_value=4_000_000_000): |
| 213 | collector.on_block_evicted(block) |
| 214 | |
| 215 | events = collector.drain_events() |
| 216 | assert len(events) == 1 |
| 217 | |
| 218 | event = events[0] |
| 219 | # Lifetime: 1.0s → 4.0s. |
| 220 | assert abs(event.lifetime_seconds - 3.0) < 1e-6 |
| 221 | # Idle: last access at 3.0s, evicted at 4.0s. |
| 222 | assert abs(event.idle_seconds - 1.0) < 1e-6 |
| 223 | # One reuse gap between the two accesses. |
| 224 | assert event.reuse_gaps_seconds == (1.0,) |