[HiCache][LoRA] Isolate storage pages by extra key (#38577)
Co-authored-by: Shuwen Wang <47200617+alphabetc1@users.noreply.github.com>
This commit is contained in:
co-authored by
Shuwen Wang
parent
b9cb96496d
commit
0b415fa573
@@ -0,0 +1,156 @@
|
||||
"""Storage round trips preserve LoRA and salt isolation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from sglang.benchmark.utils import get_tokenizer
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import (
|
||||
DEFAULT_MODEL_NAME_FOR_TEST,
|
||||
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
DEFAULT_URL_FOR_TEST,
|
||||
CustomTestCase,
|
||||
popen_launch_server,
|
||||
terminate_and_kill_process_tree,
|
||||
)
|
||||
|
||||
register_cuda_ci(est_time=300, stage="base-b", runner_config="1-gpu-large")
|
||||
|
||||
LORA_NAME = "sql"
|
||||
LORA_PATH = "philschmid/code-llama-3-1-8b-text-to-sql-lora"
|
||||
PAGE_SIZE = 64
|
||||
PROMPT_TOKENS = 768
|
||||
|
||||
|
||||
class TestHiCacheStorageLoRAIsolation(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp_dir = tempfile.mkdtemp()
|
||||
cls.model = DEFAULT_MODEL_NAME_FOR_TEST
|
||||
cls.base_url = DEFAULT_URL_FOR_TEST
|
||||
cls.tokenizer = get_tokenizer(cls.model)
|
||||
|
||||
extra_config = {"hicache_storage_pass_prefix_keys": True}
|
||||
other_args = [
|
||||
"--enable-hierarchical-cache",
|
||||
"--mem-fraction-static",
|
||||
"0.6",
|
||||
"--hicache-ratio",
|
||||
"1.2",
|
||||
"--page-size",
|
||||
str(PAGE_SIZE),
|
||||
"--enable-cache-report",
|
||||
"--hicache-storage-prefetch-policy",
|
||||
"wait_complete",
|
||||
"--hicache-storage-backend",
|
||||
"file",
|
||||
"--hicache-storage-backend-extra-config",
|
||||
json.dumps(extra_config),
|
||||
"--enable-lora",
|
||||
"--lora-paths",
|
||||
f"{LORA_NAME}={LORA_PATH}",
|
||||
"--max-loras-per-batch",
|
||||
"2",
|
||||
# Triton keeps radix caching enabled under deterministic inference.
|
||||
"--enable-deterministic-inference",
|
||||
"--attention-backend",
|
||||
"triton",
|
||||
]
|
||||
env = {**os.environ, "SGLANG_HICACHE_FILE_BACKEND_STORAGE_DIR": cls.temp_dir}
|
||||
cls.process = popen_launch_server(
|
||||
cls.model,
|
||||
cls.base_url,
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
other_args=other_args,
|
||||
env=env,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if getattr(cls, "process", None):
|
||||
terminate_and_kill_process_tree(cls.process)
|
||||
shutil.rmtree(cls.temp_dir, ignore_errors=True)
|
||||
|
||||
def send_request(
|
||||
self,
|
||||
prompt: str,
|
||||
lora_path: Optional[str],
|
||||
max_tokens: int = 32,
|
||||
cache_salt: Optional[str] = None,
|
||||
) -> Dict:
|
||||
payload = {
|
||||
"text": prompt,
|
||||
"sampling_params": {
|
||||
"temperature": 0.0,
|
||||
"max_new_tokens": max_tokens,
|
||||
"ignore_eos": True,
|
||||
},
|
||||
}
|
||||
if lora_path is not None:
|
||||
payload["lora_path"] = lora_path
|
||||
if cache_salt is not None:
|
||||
payload["cache_salt"] = cache_salt
|
||||
response = requests.post(f"{self.base_url}/generate", json=payload, timeout=120)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def cached_tokens(response_json: Dict) -> int:
|
||||
return int(response_json.get("meta_info", {}).get("cached_tokens", 0))
|
||||
|
||||
def flush_device_cache(self):
|
||||
# A short unrelated request first so the pages of interest get offloaded.
|
||||
self.send_request(self.gen_prompt(1), lora_path=None, max_tokens=150)
|
||||
res = requests.post(
|
||||
f"{self.base_url}/flush_cache", params={"timeout": 30}, timeout=40
|
||||
)
|
||||
res.raise_for_status()
|
||||
|
||||
def gen_prompt(self, token_num: int) -> str:
|
||||
vocab = list(self.tokenizer.get_vocab().values())
|
||||
return self.tokenizer.decode(random.choices(vocab, k=token_num))
|
||||
|
||||
def test_adapter_pages_are_isolated_in_storage(self):
|
||||
prompt = self.gen_prompt(PROMPT_TOKENS)
|
||||
hit_floor = PROMPT_TOKENS - 2 * PAGE_SIZE
|
||||
|
||||
# Cold pass with the adapter populates host and storage.
|
||||
lora_first = self.send_request(prompt, lora_path=LORA_NAME)
|
||||
self.flush_device_cache()
|
||||
|
||||
# Read adapter pages before any base request stores the same prompt.
|
||||
lora_again = self.send_request(prompt, lora_path=LORA_NAME)
|
||||
self.assertGreater(
|
||||
self.cached_tokens(lora_again),
|
||||
hit_floor,
|
||||
"the adapter's pages were not served from storage after the flush",
|
||||
)
|
||||
self.assertEqual(lora_first["text"], lora_again["text"])
|
||||
self.flush_device_cache()
|
||||
|
||||
# Base and salted pages must miss existing namespaces, then round-trip.
|
||||
for cache_salt in (None, "tenant-a"):
|
||||
with self.subTest(cache_salt=cache_salt):
|
||||
first = self.send_request(prompt, lora_path=None, cache_salt=cache_salt)
|
||||
self.assertLess(self.cached_tokens(first), PAGE_SIZE)
|
||||
self.flush_device_cache()
|
||||
|
||||
again = self.send_request(prompt, lora_path=None, cache_salt=cache_salt)
|
||||
self.assertGreater(self.cached_tokens(again), hit_floor)
|
||||
self.assertEqual(first["text"], again["text"])
|
||||
self.flush_device_cache()
|
||||
|
||||
lora_third = self.send_request(prompt, lora_path=LORA_NAME)
|
||||
self.assertGreater(self.cached_tokens(lora_third), hit_floor)
|
||||
self.assertEqual(lora_first["text"], lora_third["text"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -41,7 +41,7 @@ class TestDecodeHiCacheTreeCore(CustomTestCase):
|
||||
rid="req-0",
|
||||
origin_input_ids=[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
extra_key="model",
|
||||
cache_salt=None,
|
||||
cache_salt="tenant-a",
|
||||
)
|
||||
result = SimpleNamespace(
|
||||
device_indices=torch.tensor([10, 11]),
|
||||
@@ -56,7 +56,12 @@ class TestDecodeHiCacheTreeCore(CustomTestCase):
|
||||
|
||||
self.assertEqual(prefix_match.l3_storage_hit_length, 2)
|
||||
tree_cache.query_storage_hit_length.assert_called_once_with(
|
||||
22, [4, 5, 6, 7], "h2", ["h0", "h1"]
|
||||
22,
|
||||
[4, 5, 6, 7],
|
||||
"h2",
|
||||
["h0", "h1"],
|
||||
extra_key="model",
|
||||
cache_salt="tenant-a",
|
||||
)
|
||||
|
||||
DecodeHiCachePreallocMixin._start_hicache_prefetch(harness, req, prefix_match)
|
||||
@@ -69,7 +74,7 @@ class TestDecodeHiCacheTreeCore(CustomTestCase):
|
||||
"h2",
|
||||
["h0", "h1"],
|
||||
extra_key="model",
|
||||
cache_salt=None,
|
||||
cache_salt="tenant-a",
|
||||
)
|
||||
|
||||
def test_stale_prefetch_anchor_degrades_to_l2(self):
|
||||
|
||||
@@ -26,6 +26,8 @@ from sglang.srt.managers.scheduler_components.batch_result_processor import (
|
||||
)
|
||||
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
|
||||
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
|
||||
from sglang.srt.mem_cache.radix_cache import RadixKey
|
||||
from sglang.srt.mem_cache.utils import get_hash_str, get_storage_hash_str
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
@@ -44,6 +46,8 @@ def _make_mock_req(
|
||||
"""Create a mock Req with the KV cache state needed for testing."""
|
||||
req = MagicMock()
|
||||
req.rid = rid
|
||||
req.extra_key = None # base traffic: storage hashes chain from tokens alone
|
||||
req.cache_salt = None
|
||||
req.origin_input_ids = list(range(origin_len))
|
||||
req.kv = ReqKvInfo(
|
||||
req_pool_idx=req_pool_idx,
|
||||
@@ -121,6 +125,26 @@ class _FinishedEvent:
|
||||
class TestReleaseFinishedReq(unittest.TestCase):
|
||||
"""Tests for _release_finished_req overallocation cleanup."""
|
||||
|
||||
def test_decode_offload_hash_chain_matches_prefill(self):
|
||||
"""Decode pages must keep the prefill namespace across offload chunks."""
|
||||
manager, _ = _make_manager(pool_size=8, page_size=2)
|
||||
manager.cache_controller = MagicMock(get_hash_str=get_hash_str)
|
||||
tokens = [1, 2, 3, 4, 5, 6]
|
||||
for extra_key, cache_salt in [
|
||||
(None, None),
|
||||
("lora-a", None),
|
||||
(None, "tenant-a"),
|
||||
("lora-a", "tenant-a"),
|
||||
]:
|
||||
with self.subTest(extra_key=extra_key, cache_salt=cache_salt):
|
||||
namespace = dict(extra_key=extra_key, cache_salt=cache_salt)
|
||||
prefix = manager._compute_prefix_hash(tokens[:4], **namespace)
|
||||
tail = manager._compute_prefix_hash(tokens[4:], prefix[-1], **namespace)
|
||||
self.assertEqual(
|
||||
prefix + tail,
|
||||
get_storage_hash_str(RadixKey(tokens, **namespace), page_size=2),
|
||||
)
|
||||
|
||||
def test_no_overallocation(self):
|
||||
"""Without spec v2, kv_committed == kv_allocated; no extra free."""
|
||||
manager, freed = _make_manager(pool_size=32)
|
||||
|
||||
@@ -56,10 +56,11 @@ def _legacy_page_hashes(key, page_size, prior_hash=None):
|
||||
|
||||
|
||||
class _HashKey:
|
||||
def __init__(self, token_ids, is_bigram=False, cache_salt=None):
|
||||
def __init__(self, token_ids, is_bigram=False, cache_salt=None, extra_key=None):
|
||||
self.token_ids = token_ids
|
||||
self.is_bigram = is_bigram
|
||||
self.cache_salt = cache_salt
|
||||
self.extra_key = extra_key
|
||||
|
||||
def __len__(self):
|
||||
if self.is_bigram:
|
||||
@@ -75,8 +76,13 @@ class _HashKey:
|
||||
self.token_ids[start : stop + 1],
|
||||
is_bigram=True,
|
||||
cache_salt=self.cache_salt,
|
||||
extra_key=self.extra_key,
|
||||
)
|
||||
return _HashKey(self.token_ids[start:stop], cache_salt=self.cache_salt)
|
||||
return _HashKey(
|
||||
self.token_ids[start:stop],
|
||||
cache_salt=self.cache_salt,
|
||||
extra_key=self.extra_key,
|
||||
)
|
||||
if self.is_bigram:
|
||||
return (self.token_ids[index], self.token_ids[index + 1])
|
||||
return self.token_ids[index]
|
||||
@@ -267,6 +273,52 @@ class TestGetHashStr(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestStorageHashNamespace(unittest.TestCase):
|
||||
def test_node_hashes_isolate_namespaces_and_continue_the_chain(self):
|
||||
root = SimpleNamespace(parent=None, key=_HashKey(array("q")), hash_value=None)
|
||||
tokens = array("q", range(1, 129))
|
||||
|
||||
def child(extra_key=None, cache_salt=None):
|
||||
return SimpleNamespace(
|
||||
parent=root,
|
||||
key=_HashKey(tokens, extra_key=extra_key, cache_salt=cache_salt),
|
||||
hash_value=None,
|
||||
)
|
||||
|
||||
plain = compute_node_hash_values(child(), page_size=64)
|
||||
self.assertEqual(plain, get_hash_str(tokens, None, page_size=64))
|
||||
# Also guard ambiguous concatenations: ("a", "bc") vs ("ab", "c").
|
||||
namespaced = [
|
||||
compute_node_hash_values(child(*namespace), page_size=64)
|
||||
for namespace in [
|
||||
("lora-a", None),
|
||||
("lora-b", None),
|
||||
(None, "tenant-a"),
|
||||
("lora-a", "tenant-a"),
|
||||
("a", "bc"),
|
||||
("ab", "c"),
|
||||
]
|
||||
]
|
||||
for i in range(len(plain)):
|
||||
page_hashes = {plain[i], *(hashes[i] for hashes in namespaced)}
|
||||
self.assertEqual(len(page_hashes), 1 + len(namespaced))
|
||||
|
||||
# Continue the parent chain without re-seeding.
|
||||
parent = child("lora-a", "tenant-a")
|
||||
parent.hash_value = namespaced[3]
|
||||
grand = SimpleNamespace(
|
||||
parent=parent,
|
||||
key=_HashKey(
|
||||
array("q", range(200, 264)), extra_key="lora-a", cache_salt="tenant-a"
|
||||
),
|
||||
hash_value=None,
|
||||
)
|
||||
self.assertEqual(
|
||||
compute_node_hash_values(grand, page_size=64),
|
||||
get_hash_str(array("q", range(200, 264)), namespaced[3][-1], page_size=64),
|
||||
)
|
||||
|
||||
|
||||
class TestHashStrToInt64(unittest.TestCase):
|
||||
def test_zero_hash(self):
|
||||
result = hash_str_to_int64("0" * 64)
|
||||
@@ -333,10 +385,6 @@ class TestComputeNodeHashValues(unittest.TestCase):
|
||||
compute_node_event_hash_values(self._make_node(key), page_size=8),
|
||||
_legacy_page_hashes(key, page_size=8, prior_hash=seed),
|
||||
)
|
||||
self.assertEqual(
|
||||
compute_node_hash_values(self._make_node(key), page_size=8),
|
||||
_legacy_page_hashes(key, page_size=8),
|
||||
)
|
||||
|
||||
other = _HashKey(array("q", range(1, 17)), cache_salt="tenant-b")
|
||||
self.assertNotEqual(
|
||||
|
||||
@@ -796,6 +796,32 @@ class TestRadixCache(CustomTestCase):
|
||||
]
|
||||
self.assertNotEqual(unsalted_hashes, stored[0].block_hashes)
|
||||
|
||||
def test_extra_key_does_not_move_published_block_hashes(self):
|
||||
"""Adding extra_key preserves event hashes and split-parent links."""
|
||||
for cache_salt in (None, "tenant-a"):
|
||||
published = []
|
||||
for extra_key in (None, "lora-a"):
|
||||
cache = RadixCache.create_simulated(
|
||||
page_size=2, enable_kv_cache_events=True
|
||||
)
|
||||
namespace = dict(extra_key=extra_key, cache_salt=cache_salt)
|
||||
for tokens in ([1, 2, 3, 4, 5, 6], [1, 2, 7, 8]):
|
||||
cache.insert(
|
||||
InsertParams(
|
||||
key=RadixKey(array("q", tokens), **namespace),
|
||||
value=torch.tensor(tokens, dtype=torch.int64),
|
||||
)
|
||||
)
|
||||
published.append(
|
||||
[
|
||||
(event.parent_block_hash, tuple(event.block_hashes))
|
||||
for event in cache.take_events()
|
||||
if isinstance(event, BlockStored)
|
||||
]
|
||||
)
|
||||
self.assertEqual(published[0], published[1])
|
||||
self.assertIsNotNone(published[1][-1][0])
|
||||
|
||||
def test_cache_salt_event_hashes_are_preserved_across_node_split(self):
|
||||
cache = RadixCache.create_simulated(page_size=2, enable_kv_cache_events=True)
|
||||
original = RadixKey(array("q", [1, 2, 3, 4]), cache_salt="tenant-a")
|
||||
|
||||
@@ -52,7 +52,7 @@ from sglang.srt.mem_cache.unified_cache.cache_action import (
|
||||
SWARebuild,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_cache.component_type import ComponentType
|
||||
from sglang.srt.mem_cache.utils import hash_str_to_int64
|
||||
from sglang.srt.mem_cache.utils import get_storage_hash_str, hash_str_to_int64
|
||||
from sglang.srt.runtime_context import get_context
|
||||
|
||||
|
||||
@@ -1058,21 +1058,25 @@ def test_storage_backup_spec_round_trips_the_backuped_node():
|
||||
core = _tree_core(page_size=2)
|
||||
core.set_hicache_enabled()
|
||||
core.enable_storage = True
|
||||
_insert(core, [1, 2], [10, 11])
|
||||
_insert(core, [1, 2, 7, 8], [10, 11, 12, 13])
|
||||
parent = core.match_prefix(MatchPrefixParams(key=_key([1, 2]))).best_match_node
|
||||
child = core.match_prefix(MatchPrefixParams(key=_key([1, 2, 7, 8]))).best_match_node
|
||||
key = RadixKey(
|
||||
array("q", [1, 2, 7, 8]), extra_key="adapter-a", cache_salt="tenant-a"
|
||||
)
|
||||
for length in (2, 4):
|
||||
_pump_insert(
|
||||
core,
|
||||
InsertParams(key=key[:length], value=torch.arange(10, 10 + length)),
|
||||
)
|
||||
parent = core.match_prefix(MatchPrefixParams(key=key[:2])).best_match_node
|
||||
child = core.match_prefix(MatchPrefixParams(key=key)).best_match_node
|
||||
core.commit_backup(parent, torch.tensor([100, 101], dtype=torch.int64), {})
|
||||
core.commit_backup(child, torch.tensor([102, 103], dtype=torch.int64), {})
|
||||
|
||||
spec = core.build_storage_backup_spec(child, pass_prefix_keys=True)
|
||||
assert spec.host_value.tolist() == [102, 103]
|
||||
assert spec.token_ids == array("q", [7, 8])
|
||||
parent_hashes = mem_cache.get_hash_str(array("q", [1, 2]), None, 2)
|
||||
assert spec.prefix_keys == parent_hashes
|
||||
assert spec.hash_value == mem_cache.get_hash_str(
|
||||
array("q", [7, 8]), parent_hashes[-1], 2
|
||||
)
|
||||
hashes = get_storage_hash_str(key, page_size=2)
|
||||
assert spec.prefix_keys == hashes[:1]
|
||||
assert spec.hash_value == hashes[1:]
|
||||
assert spec.comp_xfers == {}
|
||||
|
||||
|
||||
|
||||
@@ -3912,10 +3912,18 @@ class UnifiedRadixCacheSuite:
|
||||
storage_dir, seq, extra_key=extra_key, cache_salt=cache_salt
|
||||
)
|
||||
|
||||
# A root anchor has no namespace of its own. The fetched span must use
|
||||
# the request namespace supplied to prefetch_from_storage.
|
||||
# A root anchor has no namespace; probe and prefetch must use the request's.
|
||||
cons, _, _ = build_fixture(self.cfg)
|
||||
self._init_buffer_hicache(cons, storage_dir)
|
||||
self.assertEqual(
|
||||
cons.query_storage_hit_length(
|
||||
cons.root_node_handle(),
|
||||
array("q", seq),
|
||||
extra_key=extra_key,
|
||||
cache_salt=cache_salt,
|
||||
),
|
||||
len(seq),
|
||||
)
|
||||
root_req = "salted-root-prefetch"
|
||||
cons.prefetch_from_storage(
|
||||
root_req,
|
||||
|
||||
Reference in New Issue
Block a user