Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

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