59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
285 lines
10 KiB
Python
285 lines
10 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from tokenspeed.runtime.cache.prefix_cache import CacheInitParams, PrefixCache, TreeNode
|
|
|
|
|
|
class MockAllocator:
|
|
def __init__(self, page_size: int = 1):
|
|
self.page_size = page_size
|
|
self.freed: list[torch.Tensor] = []
|
|
|
|
def append_to_later_free(self, t: torch.Tensor) -> None:
|
|
self.freed.append(t)
|
|
|
|
def free_group_end(self) -> None:
|
|
pass
|
|
|
|
def free_with_diff(self, a, b) -> None:
|
|
pass
|
|
|
|
|
|
def _make_cache(page_size: int = 1, policy: str = "lru") -> PrefixCache:
|
|
alloc = MockAllocator(page_size=page_size)
|
|
params = CacheInitParams(
|
|
disable=False,
|
|
req_to_token_pool=None,
|
|
token_to_kv_pool_allocator=alloc,
|
|
page_size=page_size,
|
|
eviction_policy=policy,
|
|
)
|
|
return PrefixCache(params)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _insert(cache: PrefixCache, key_tuples: list[tuple], page_ids: list[int]) -> None:
|
|
value = torch.tensor(page_ids, dtype=torch.int32)
|
|
cache.insert(key_tuples, value)
|
|
|
|
|
|
def _leaves(cache: PrefixCache) -> set[TreeNode]:
|
|
"""Collect all leaf nodes via DFS (ground-truth, independent of evictable_leaves)."""
|
|
result = set()
|
|
stack = [cache.root_node]
|
|
while stack:
|
|
node = stack.pop()
|
|
if not node.children:
|
|
result.add(node)
|
|
else:
|
|
stack.extend(node.children.values())
|
|
return result
|
|
|
|
|
|
def _evictable_leaves_ground_truth(cache: PrefixCache) -> set[TreeNode]:
|
|
return {n for n in _leaves(cache) if n.lock_ref == 0 and n != cache.root_node}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# evictable_leaves invariant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvictableLeaves:
|
|
def test_empty_cache(self):
|
|
cache = _make_cache()
|
|
assert cache.evictable_leaves == set()
|
|
|
|
def test_single_insert(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,)], [10])
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
assert len(cache.evictable_leaves) == 1
|
|
|
|
def test_two_independent_sequences(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,)], [10])
|
|
_insert(cache, [(2,)], [20])
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
assert len(cache.evictable_leaves) == 2
|
|
|
|
def test_shared_prefix_split(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,), (2,), (3,)], [10, 11, 12])
|
|
_insert(cache, [(1,), (2,), (4,)], [10, 11, 13])
|
|
# Tree: root → [(1,),(2,)] → [(3,)] leaf and [(4,)] leaf
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
assert len(cache.evictable_leaves) == 2
|
|
|
|
def test_parent_removed_from_set_when_child_inserted(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,)], [10])
|
|
leaf = next(iter(cache.evictable_leaves))
|
|
assert leaf in cache.evictable_leaves
|
|
# Insert a child under the same path — creates a longer chain
|
|
_insert(cache, [(1,), (2,)], [10, 11])
|
|
# The node for (1,) now has a child, must not be in evictable_leaves
|
|
assert leaf not in cache.evictable_leaves
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
|
|
def test_after_eviction(self):
|
|
cache = _make_cache()
|
|
for i in range(5):
|
|
_insert(cache, [(i,)], [i])
|
|
assert len(cache.evictable_leaves) == 5
|
|
cache.evict(3)
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
|
|
def test_cascade_eviction_restores_parent_to_set(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,), (2,)], [10, 11])
|
|
_insert(cache, [(1,), (3,)], [10, 12])
|
|
# root → (1,) internal → (2,) leaf, (3,) leaf
|
|
assert len(cache.evictable_leaves) == 2
|
|
# Evict both leaves — the parent (1,) should become evictable
|
|
cache.evict(10)
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
|
|
def test_inc_dec_lock_ref(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,)], [10])
|
|
leaf = next(iter(cache.evictable_leaves))
|
|
cache.inc_lock_ref(leaf)
|
|
assert leaf not in cache.evictable_leaves
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
cache.dec_lock_ref(leaf)
|
|
assert leaf in cache.evictable_leaves
|
|
assert cache.evictable_leaves == _evictable_leaves_ground_truth(cache)
|
|
|
|
def test_locked_internal_node_not_in_evictable_leaves(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,), (2,)], [10, 11])
|
|
_insert(cache, [(1,), (3,)], [10, 12])
|
|
# Find the internal node for (1,) — it has two children
|
|
internal = cache.root_node.children[(1,)]
|
|
assert len(internal.children) == 2
|
|
# Internal node with children is never evictable
|
|
assert internal not in cache.evictable_leaves
|
|
assert internal not in _evictable_leaves_ground_truth(cache)
|
|
|
|
def test_reset_clears_set(self):
|
|
cache = _make_cache()
|
|
for i in range(10):
|
|
_insert(cache, [(i,)], [i])
|
|
assert len(cache.evictable_leaves) == 10
|
|
cache.reset()
|
|
assert cache.evictable_leaves == set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Eviction correctness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEviction:
|
|
def test_evict_returns_count(self):
|
|
cache = _make_cache()
|
|
for i in range(10):
|
|
_insert(cache, [(i,)], [i])
|
|
evicted = cache.evict(5)
|
|
assert evicted == 5
|
|
assert cache.evictable_size() == 5
|
|
|
|
def test_evict_respects_lock_ref(self):
|
|
cache = _make_cache()
|
|
for i in range(5):
|
|
_insert(cache, [(i,)], [i])
|
|
locked = cache.root_node.children[(0,)]
|
|
cache.inc_lock_ref(locked)
|
|
evicted = cache.evict(5)
|
|
# Can only evict 4 — the locked leaf is protected
|
|
assert evicted == 4
|
|
|
|
def test_evict_all_then_empty(self):
|
|
cache = _make_cache()
|
|
for i in range(3):
|
|
_insert(cache, [(i,)], [i])
|
|
cache.evict(100)
|
|
assert cache.evictable_size() == 0
|
|
assert len(cache.evictable_leaves) == 0
|
|
|
|
def test_evict_cascade(self):
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,), (2,)], [10, 11])
|
|
_insert(cache, [(1,), (3,)], [10, 12])
|
|
# Evicting both leaves should cascade to free (1,) as well
|
|
cache.evict(10)
|
|
assert cache.evictable_size() == 0
|
|
|
|
def test_evict_freed_pages(self):
|
|
alloc = MockAllocator()
|
|
params = CacheInitParams(
|
|
disable=False,
|
|
req_to_token_pool=None,
|
|
token_to_kv_pool_allocator=alloc,
|
|
page_size=1,
|
|
)
|
|
cache = PrefixCache(params)
|
|
for i in range(3):
|
|
cache.insert([(i,)], torch.tensor([i], dtype=torch.int32))
|
|
cache.evict(3)
|
|
freed_flat = [v.item() for t in alloc.freed for v in t]
|
|
assert sorted(freed_flat) == [0, 1, 2]
|
|
|
|
def test_lru_order(self):
|
|
import time
|
|
|
|
cache = _make_cache(policy="lru")
|
|
for i in range(3):
|
|
cache.insert([(i,)], torch.tensor([i], dtype=torch.int32))
|
|
time.sleep(0.001)
|
|
# (0,) is LRU — should be evicted first
|
|
alloc = cache.token_to_kv_pool_allocator
|
|
cache.evict(1)
|
|
assert len(alloc.freed) == 1
|
|
assert alloc.freed[0].item() == 0
|
|
|
|
def test_evict_disabled_cache_noop(self):
|
|
alloc = MockAllocator()
|
|
params = CacheInitParams(
|
|
disable=True,
|
|
req_to_token_pool=None,
|
|
token_to_kv_pool_allocator=alloc,
|
|
page_size=1,
|
|
)
|
|
cache = PrefixCache(params)
|
|
result = cache.evict(100)
|
|
assert result is None
|
|
assert alloc.freed == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# delete_leaf O(1) key lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteLeaf:
|
|
def test_delete_by_key_not_linear_scan(self):
|
|
"""Ensure _delete_leaf uses node.key[0] as the dict key, not a linear scan."""
|
|
cache = _make_cache()
|
|
# Build a flat tree with many siblings under root
|
|
for i in range(100):
|
|
_insert(cache, [(i,)], [i])
|
|
# Grab a leaf and delete it — should not raise and should reduce size
|
|
leaf = next(iter(cache.evictable_leaves))
|
|
before = len(cache.root_node.children)
|
|
cache._delete_leaf(leaf)
|
|
assert len(cache.root_node.children) == before - 1
|
|
assert leaf not in cache.evictable_leaves
|
|
|
|
def test_delete_leaf_after_split(self):
|
|
"""After a split, the leaf's key[0] must still be the correct parent dict key."""
|
|
cache = _make_cache()
|
|
_insert(cache, [(1,), (2,), (3,)], [10, 11, 12])
|
|
_insert(cache, [(1,), (2,), (4,)], [10, 11, 13])
|
|
# evictable leaves are the two split children
|
|
for leaf in list(cache.evictable_leaves):
|
|
before = len(leaf.parent.children)
|
|
cache._delete_leaf(leaf)
|
|
assert len(leaf.parent.children) == before - 1
|