376 lines
14 KiB
Python
376 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for TokenHasher."""
|
|
|
|
# Standard
|
|
import builtins
|
|
|
|
# Third Party
|
|
import numpy as np
|
|
import pytest
|
|
|
|
# First Party
|
|
from lmcache.v1.multiprocess.token_hasher import (
|
|
TokenHasher,
|
|
chunk_hash_windows_numba,
|
|
rolling_hash_windows_numba,
|
|
unique_hits_direct_id_numba,
|
|
update_table_id_numba,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def hasher() -> TokenHasher:
|
|
"""TokenHasher with small chunk_size for testing."""
|
|
return TokenHasher(chunk_size=4, hash_algorithm="blake3")
|
|
|
|
|
|
class TestTokenHasher:
|
|
def test_init_blake3(self) -> None:
|
|
hasher = TokenHasher(chunk_size=256, hash_algorithm="blake3")
|
|
assert hasher.chunk_size == 256
|
|
assert hasher.none_hash is not None
|
|
|
|
def test_init_blake3_does_not_import_vllm(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
real_import = builtins.__import__
|
|
|
|
def guarded_import(name, *args, **kwargs):
|
|
if name == "vllm" or name.startswith("vllm."):
|
|
raise AssertionError("blake3 TokenHasher should not import vLLM")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", guarded_import)
|
|
|
|
hasher = TokenHasher(chunk_size=256, hash_algorithm="blake3")
|
|
|
|
assert hasher.none_hash is not None
|
|
|
|
def test_init_none_hash_vllm_runtime_error_falls_back(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
real_import = builtins.__import__
|
|
|
|
def failing_vllm_import(name, *args, **kwargs):
|
|
if name == "vllm.v1.core":
|
|
raise RuntimeError("already a kernel registered")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", failing_vllm_import)
|
|
hasher = TokenHasher.__new__(TokenHasher)
|
|
hasher.hash_algorithm_name = "sha256_cbor"
|
|
hasher.hash_func = lambda _: b"fallback"
|
|
|
|
assert hasher._init_none_hash() == b"fallback"
|
|
|
|
def test_hash_tokens_returns_bytes(self, hasher: TokenHasher) -> None:
|
|
h = hasher.hash_tokens([1, 2, 3, 4])
|
|
assert isinstance(h, bytes)
|
|
|
|
def test_hash_tokens_deterministic(self, hasher: TokenHasher) -> None:
|
|
h1 = hasher.hash_tokens([1, 2, 3, 4])
|
|
h2 = hasher.hash_tokens([1, 2, 3, 4])
|
|
assert h1 == h2
|
|
|
|
def test_hash_tokens_different_input(self, hasher: TokenHasher) -> None:
|
|
h1 = hasher.hash_tokens([1, 2, 3, 4])
|
|
h2 = hasher.hash_tokens([5, 6, 7, 8])
|
|
assert h1 != h2
|
|
|
|
def test_hash_tokens_with_prefix(self, hasher: TokenHasher) -> None:
|
|
"""Rolling hash: same tokens with different prefix produces different hash."""
|
|
h_no_prefix = hasher.hash_tokens([1, 2, 3, 4])
|
|
h_with_prefix = hasher.hash_tokens([1, 2, 3, 4], prefix_hash=h_no_prefix)
|
|
assert h_no_prefix != h_with_prefix
|
|
|
|
def test_compute_chunk_hashes_exact_chunks(self, hasher: TokenHasher) -> None:
|
|
"""8 tokens with chunk_size=4 produces 2 hashes."""
|
|
tokens = list(range(8))
|
|
hashes = hasher.compute_chunk_hashes(tokens)
|
|
assert len(hashes) == 2
|
|
|
|
def test_compute_chunk_hashes_partial_chunk_discarded(
|
|
self, hasher: TokenHasher
|
|
) -> None:
|
|
"""10 tokens with chunk_size=4 produces 2 hashes (last 2 tokens discarded)."""
|
|
tokens = list(range(10))
|
|
hashes = hasher.compute_chunk_hashes(tokens)
|
|
assert len(hashes) == 2
|
|
|
|
def test_compute_chunk_hashes_too_few_tokens(self, hasher: TokenHasher) -> None:
|
|
"""3 tokens with chunk_size=4 produces 0 hashes."""
|
|
hashes = hasher.compute_chunk_hashes([1, 2, 3])
|
|
assert len(hashes) == 0
|
|
|
|
def test_compute_chunk_hashes_empty(self, hasher: TokenHasher) -> None:
|
|
hashes = hasher.compute_chunk_hashes([])
|
|
assert len(hashes) == 0
|
|
|
|
def test_compute_chunk_hashes_rolling(self, hasher: TokenHasher) -> None:
|
|
"""Second chunk hash depends on the first (rolling property)."""
|
|
tokens = list(range(8))
|
|
hashes = hasher.compute_chunk_hashes(tokens)
|
|
# Hash of chunk [4,5,6,7] alone (no prefix) should differ
|
|
standalone = hasher.hash_tokens([4, 5, 6, 7])
|
|
assert hashes[1] != standalone
|
|
|
|
def test_compute_chunk_hashes_matches_manual(self, hasher: TokenHasher) -> None:
|
|
"""compute_chunk_hashes should match manual rolling hash_tokens calls."""
|
|
tokens = list(range(12)) # 3 chunks
|
|
auto_hashes = hasher.compute_chunk_hashes(tokens)
|
|
|
|
h0 = hasher.hash_tokens(tokens[0:4])
|
|
h1 = hasher.hash_tokens(tokens[4:8], prefix_hash=h0)
|
|
h2 = hasher.hash_tokens(tokens[8:12], prefix_hash=h1)
|
|
assert auto_hashes == [h0, h1, h2]
|
|
|
|
def test_hash_to_bytes_from_bytes(self) -> None:
|
|
val = b"\x01\x02\x03"
|
|
assert TokenHasher.hash_to_bytes(val) is val
|
|
|
|
def test_hash_to_bytes_from_int(self) -> None:
|
|
val = 42
|
|
result = TokenHasher.hash_to_bytes(val)
|
|
assert isinstance(result, bytes)
|
|
assert len(result) == 8
|
|
|
|
|
|
_BASE = np.uint64(31)
|
|
|
|
|
|
class TestRollingHashWindowsNumba:
|
|
def test_output_length(self) -> None:
|
|
arr = np.arange(10, dtype=np.uint64)
|
|
out = rolling_hash_windows_numba(arr, 4, _BASE)
|
|
assert len(out) == 7 # 10 - 4 + 1
|
|
|
|
def test_single_window(self) -> None:
|
|
arr = np.array([1, 2, 3], dtype=np.uint64)
|
|
out = rolling_hash_windows_numba(arr, 3, _BASE)
|
|
assert len(out) == 1
|
|
|
|
def test_k_equals_1(self) -> None:
|
|
"""With window size 1 each output equals the corresponding input element."""
|
|
arr = np.array([7, 13, 42], dtype=np.uint64)
|
|
out = rolling_hash_windows_numba(arr, 1, _BASE)
|
|
np.testing.assert_array_equal(out, arr)
|
|
|
|
def test_deterministic(self) -> None:
|
|
arr = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
|
|
out1 = rolling_hash_windows_numba(arr, 3, _BASE)
|
|
out2 = rolling_hash_windows_numba(arr, 3, _BASE)
|
|
np.testing.assert_array_equal(out1, out2)
|
|
|
|
def test_different_inputs_different_outputs(self) -> None:
|
|
arr1 = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
|
|
arr2 = np.array([5, 4, 3, 2, 1], dtype=np.uint64)
|
|
out1 = rolling_hash_windows_numba(arr1, 3, _BASE)
|
|
out2 = rolling_hash_windows_numba(arr2, 3, _BASE)
|
|
assert not np.array_equal(out1, out2)
|
|
|
|
def test_different_base_different_output(self) -> None:
|
|
arr = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
|
|
out1 = rolling_hash_windows_numba(arr, 3, np.uint64(31))
|
|
out2 = rolling_hash_windows_numba(arr, 3, np.uint64(37))
|
|
assert not np.array_equal(out1, out2)
|
|
|
|
def test_manual_values(self) -> None:
|
|
"""Verify polynomial hash values against manual computation.
|
|
|
|
For arr=[1,2,3,4,5], k=3, base=31:
|
|
power = 31^2 = 961
|
|
window [1,2,3]: h = ((0*31+1)*31+2)*31+3 = 1026
|
|
window [2,3,4]: h = 1026 - 1*961 = 65; 65*31+4 = 2019
|
|
window [3,4,5]: h = 2019 - 2*961 = 97; 97*31+5 = 3012
|
|
"""
|
|
arr = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
|
|
out = rolling_hash_windows_numba(arr, 3, _BASE)
|
|
expected = np.array([1026, 2019, 3012], dtype=np.uint64)
|
|
np.testing.assert_array_equal(out, expected)
|
|
|
|
def test_output_dtype(self) -> None:
|
|
arr = np.array([1, 2, 3], dtype=np.uint64)
|
|
out = rolling_hash_windows_numba(arr, 2, _BASE)
|
|
assert out.dtype == np.uint64
|
|
|
|
|
|
class TestChunkHashWindowsNumba:
|
|
def test_output_length(self) -> None:
|
|
arr = np.arange(12, dtype=np.uint64)
|
|
out = chunk_hash_windows_numba(arr, 4, _BASE)
|
|
assert len(out) == 3 # 12 // 4
|
|
|
|
def test_partial_chunk_ignored(self) -> None:
|
|
"""Trailing tokens that don't fill a full window are dropped."""
|
|
arr = np.arange(10, dtype=np.uint64)
|
|
out = chunk_hash_windows_numba(arr, 4, _BASE)
|
|
assert len(out) == 2 # 10 // 4
|
|
|
|
def test_no_full_windows(self) -> None:
|
|
arr = np.arange(3, dtype=np.uint64)
|
|
out = chunk_hash_windows_numba(arr, 4, _BASE)
|
|
assert len(out) == 0
|
|
|
|
def test_deterministic(self) -> None:
|
|
arr = np.arange(8, dtype=np.uint64)
|
|
out1 = chunk_hash_windows_numba(arr, 4, _BASE)
|
|
out2 = chunk_hash_windows_numba(arr, 4, _BASE)
|
|
np.testing.assert_array_equal(out1, out2)
|
|
|
|
def test_different_inputs_different_outputs(self) -> None:
|
|
arr1 = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint64)
|
|
arr2 = np.array([6, 5, 4, 3, 2, 1], dtype=np.uint64)
|
|
out1 = chunk_hash_windows_numba(arr1, 3, _BASE)
|
|
out2 = chunk_hash_windows_numba(arr2, 3, _BASE)
|
|
assert not np.array_equal(out1, out2)
|
|
|
|
def test_first_window_matches_rolling(self) -> None:
|
|
"""First chunk hash must equal the first rolling-hash window."""
|
|
arr = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint64)
|
|
chunk_out = chunk_hash_windows_numba(arr, 3, _BASE)
|
|
rolling_out = rolling_hash_windows_numba(arr, 3, _BASE)
|
|
assert chunk_out[0] == rolling_out[0]
|
|
|
|
def test_manual_values(self) -> None:
|
|
"""Verify chunk hashes against manual computation.
|
|
|
|
For arr=[1,2,3,4,5,6], k=3, base=31:
|
|
window 0 [1,2,3]: ((0*31+1)*31+2)*31+3 = 1026
|
|
window 1 [4,5,6]: ((0*31+4)*31+5)*31+6 = 4005
|
|
"""
|
|
arr = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint64)
|
|
out = chunk_hash_windows_numba(arr, 3, _BASE)
|
|
expected = np.array([1026, 4005], dtype=np.uint64)
|
|
np.testing.assert_array_equal(out, expected)
|
|
|
|
def test_output_dtype(self) -> None:
|
|
arr = np.array([1, 2, 3, 4], dtype=np.uint64)
|
|
out = chunk_hash_windows_numba(arr, 2, _BASE)
|
|
assert out.dtype == np.uint64
|
|
|
|
|
|
class TestUpdateTableIdNumba:
|
|
def _empty_table(self, size: int = 8) -> np.ndarray:
|
|
return np.full(size, -1, dtype=np.int64)
|
|
|
|
def test_basic_update(self) -> None:
|
|
table = self._empty_table()
|
|
hashes = np.array([0], dtype=np.uint64) # 0 & 7 = 0
|
|
vals = np.array([99], dtype=np.int64)
|
|
update_table_id_numba(hashes, table, vals)
|
|
assert table[0] == 99
|
|
|
|
def test_index_from_hash_lower_bits(self) -> None:
|
|
"""idx = hash & (table_size - 1)."""
|
|
table = self._empty_table(8) # mask = 7
|
|
hashes = np.array([9], dtype=np.uint64) # 9 & 7 = 1
|
|
vals = np.array([42], dtype=np.int64)
|
|
update_table_id_numba(hashes, table, vals)
|
|
assert table[1] == 42
|
|
assert all(table[i] == -1 for i in range(8) if i != 1)
|
|
|
|
def test_multiple_updates(self) -> None:
|
|
table = self._empty_table()
|
|
hashes = np.array([0, 1, 2], dtype=np.uint64)
|
|
vals = np.array([10, 20, 30], dtype=np.int64)
|
|
update_table_id_numba(hashes, table, vals)
|
|
assert table[0] == 10
|
|
assert table[1] == 20
|
|
assert table[2] == 30
|
|
|
|
def test_collision_last_write_wins(self) -> None:
|
|
"""When two hashes map to the same slot, the last value is stored."""
|
|
table = self._empty_table()
|
|
# hash 0 → idx 0, hash 8 → idx 0 (8 & 7 = 0)
|
|
hashes = np.array([0, 8], dtype=np.uint64)
|
|
vals = np.array([10, 20], dtype=np.int64)
|
|
update_table_id_numba(hashes, table, vals)
|
|
assert table[0] == 20
|
|
|
|
def test_modifies_in_place(self) -> None:
|
|
table = self._empty_table()
|
|
ptr = table.ctypes.data
|
|
hashes = np.array([3], dtype=np.uint64)
|
|
vals = np.array([7], dtype=np.int64)
|
|
update_table_id_numba(hashes, table, vals)
|
|
assert table.ctypes.data == ptr # same buffer
|
|
assert table[3] == 7
|
|
|
|
def test_empty_hashes_no_change(self) -> None:
|
|
table = self._empty_table()
|
|
hashes = np.empty(0, dtype=np.uint64)
|
|
vals = np.empty(0, dtype=np.int64)
|
|
update_table_id_numba(hashes, table, vals)
|
|
assert all(v == -1 for v in table)
|
|
|
|
|
|
class TestUniqueHitsDirectIdNumba:
|
|
def _table(self, size: int = 8) -> np.ndarray:
|
|
return np.full(size, -1, dtype=np.int64)
|
|
|
|
def test_no_hits_all_empty(self) -> None:
|
|
table = self._table()
|
|
hashes = np.array([0, 1, 2], dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 4)
|
|
assert len(out) == 0
|
|
|
|
def test_single_hit(self) -> None:
|
|
table = self._table()
|
|
table[1] = 5
|
|
hashes = np.array([1], dtype=np.uint64) # 1 & 7 = 1 → ID 5
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 10)
|
|
assert len(out) == 1
|
|
assert out[0] == 5
|
|
|
|
def test_deduplication(self) -> None:
|
|
"""Same ID reached via two different hashes is returned only once."""
|
|
table = self._table()
|
|
table[1] = 3 # index 1 → ID 3
|
|
# hash 1 and hash 9 both map to index 1 (& 7 == 1)
|
|
hashes = np.array([1, 9], dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 5)
|
|
assert len(out) == 1
|
|
assert out[0] == 3
|
|
|
|
def test_multiple_unique_ids(self) -> None:
|
|
table = self._table()
|
|
table[0] = 0
|
|
table[1] = 1
|
|
table[2] = 2
|
|
hashes = np.array([0, 1, 2], dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 5)
|
|
assert len(out) == 3
|
|
assert set(out) == {0, 1, 2}
|
|
|
|
def test_mixed_hits_and_misses(self) -> None:
|
|
table = self._table()
|
|
table[2] = 10
|
|
# indices 0 (miss), 2 (hit → 10), 5 (miss)
|
|
hashes = np.array([0, 2, 5], dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 15)
|
|
assert len(out) == 1
|
|
assert out[0] == 10
|
|
|
|
def test_order_of_first_encounter_preserved(self) -> None:
|
|
"""IDs appear in the order they are first seen in the hash stream."""
|
|
table = self._table()
|
|
table[0] = 2
|
|
table[1] = 0
|
|
table[2] = 1
|
|
hashes = np.array([0, 1, 2], dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 3)
|
|
np.testing.assert_array_equal(out, [2, 0, 1])
|
|
|
|
def test_empty_hashes(self) -> None:
|
|
table = self._table()
|
|
hashes = np.empty(0, dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 4)
|
|
assert len(out) == 0
|
|
|
|
def test_output_dtype(self) -> None:
|
|
table = self._table()
|
|
table[0] = 0
|
|
hashes = np.array([0], dtype=np.uint64)
|
|
out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 1)
|
|
assert out.dtype == np.int64
|