mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-18 00:34:55 +02:00
269 lines
7.6 KiB
Python
269 lines
7.6 KiB
Python
import base64
|
|
import glob
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
|
|
import pytest
|
|
import requests
|
|
from utils import *
|
|
|
|
server = ServerPreset.tinyllama2()
|
|
|
|
cache_dir: str = ""
|
|
|
|
|
|
class LogReader:
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.pos = 0
|
|
def drain(self):
|
|
with open(self.path) as f:
|
|
f.seek(self.pos)
|
|
content = f.read()
|
|
self.pos = f.tell()
|
|
return content
|
|
def wait_for(self, tag, timeout=10) -> bool:
|
|
# the server log is pumped to the file asynchronously - poll for the tag
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if tag in self.drain():
|
|
return True
|
|
time.sleep(0.25)
|
|
return False
|
|
|
|
|
|
def kvc_files() -> list[str]:
|
|
return sorted(glob.glob(os.path.join(cache_dir, "*.kvc")))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def create_server():
|
|
global server, cache_dir
|
|
cache_dir = tempfile.mkdtemp(prefix="llama_cache_disk_")
|
|
server = ServerPreset.tinyllama2()
|
|
server.n_slots = 1
|
|
server.temperature = 0.0
|
|
server.debug = True
|
|
server.cache_disk = cache_dir
|
|
fd, server.log_path = tempfile.mkstemp(suffix='.log')
|
|
os.close(fd)
|
|
yield
|
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
|
|
|
|
PROMPT_A = (
|
|
"Once upon a time in a land far away, there lived a brave knight "
|
|
"who traveled across mountains and rivers to find the legendary "
|
|
"golden sword hidden deep within the enchanted forest of whispers."
|
|
)
|
|
|
|
PROMPT_B = "The quick brown fox jumps over the lazy dog."
|
|
|
|
|
|
def make_prompt_request(prompt, n_predict=0):
|
|
global server
|
|
res = server.make_request("POST", "/completion", data={
|
|
"prompt": prompt,
|
|
"n_predict": n_predict, # 0 = evaluate the prompt into the KV cache only
|
|
"cache_prompt": True,
|
|
})
|
|
assert res.status_code == 200
|
|
return res
|
|
|
|
|
|
def test_write_through_and_restart_hit():
|
|
global server
|
|
server.cache_disk_write_through = True
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
res = make_prompt_request(PROMPT_A)
|
|
prompt_n_full = res.body["timings"]["prompt_n"]
|
|
assert prompt_n_full > 0
|
|
|
|
# nothing is written while the prompt is still live in the slot
|
|
assert len(kvc_files()) == 0
|
|
|
|
# a different prompt takes over the only slot - the previous one is saved
|
|
# to the RAM cache and, in write-through mode, to disk immediately
|
|
make_prompt_request(PROMPT_B)
|
|
assert log.wait_for("__TEST_TAG_CACHE_DISK_STORE__")
|
|
assert len(kvc_files()) == 1
|
|
|
|
# the state must survive a full server restart
|
|
server.stop()
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
res = make_prompt_request(PROMPT_A)
|
|
assert log.wait_for("__TEST_TAG_CACHE_DISK_HIT__")
|
|
assert res.body["timings"]["prompt_n"] == 1 # only the last token is re-evaluated
|
|
assert res.body["timings"]["cache_n"] == prompt_n_full - 1
|
|
|
|
|
|
def test_spill_on_shutdown_flush():
|
|
global server
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
make_prompt_request(PROMPT_A)
|
|
make_prompt_request(PROMPT_B) # forces PROMPT_A into the RAM cache
|
|
|
|
# without write-through, nothing reaches the disk while running
|
|
time.sleep(0.5)
|
|
assert "__TEST_TAG_CACHE_DISK_STORE__" not in log.drain()
|
|
assert len(kvc_files()) == 0
|
|
|
|
# a graceful shutdown flushes the RAM cache entries to disk
|
|
server.stop()
|
|
assert len(kvc_files()) == 1
|
|
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
res = make_prompt_request(PROMPT_A)
|
|
assert log.wait_for("__TEST_TAG_CACHE_DISK_HIT__")
|
|
assert res.body["timings"]["prompt_n"] == 1
|
|
|
|
|
|
def test_ram_cache_hit_takes_priority():
|
|
global server
|
|
server.cache_disk_write_through = True
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
make_prompt_request(PROMPT_A)
|
|
make_prompt_request(PROMPT_B)
|
|
assert len(kvc_files()) == 1
|
|
|
|
# PROMPT_A is in both the RAM cache and on disk - the RAM copy must win
|
|
# (the disk entry is never longer than the RAM one here)
|
|
res = make_prompt_request(PROMPT_A)
|
|
time.sleep(0.5)
|
|
assert "__TEST_TAG_CACHE_DISK_HIT__" not in log.drain()
|
|
assert res.body["timings"]["cache_n"] > 0
|
|
|
|
|
|
def test_budget_eviction():
|
|
global server
|
|
server.n_ctx = 2048
|
|
server.n_batch = 512
|
|
server.cache_disk_write_through = True
|
|
server.cache_disk_limit = 1 # MiB
|
|
server.start()
|
|
|
|
# three long, distinct token-array prompts; each state is close to 1 MiB
|
|
n_len = 1500
|
|
for i in range(3):
|
|
make_prompt_request([100 + i] * n_len)
|
|
|
|
# one final small prompt to force the last long prompt out of the slot
|
|
make_prompt_request(PROMPT_B)
|
|
|
|
files = kvc_files()
|
|
assert len(files) >= 1
|
|
assert len(files) < 3 # the oldest entries were evicted
|
|
|
|
# the budget is respected (a single over-budget file is allowed to remain)
|
|
if len(files) > 1:
|
|
assert sum(os.path.getsize(f) for f in files) <= 1024 * 1024
|
|
|
|
|
|
def test_corrupt_file_is_removed():
|
|
global server
|
|
server.cache_disk_write_through = True
|
|
server.start()
|
|
|
|
make_prompt_request(PROMPT_A)
|
|
make_prompt_request(PROMPT_B)
|
|
files = kvc_files()
|
|
assert len(files) == 1
|
|
|
|
server.stop()
|
|
|
|
# corrupt the serialized token section (starts right after the 48-byte header)
|
|
with open(files[0], "r+b") as f:
|
|
f.seek(48 + 4)
|
|
f.write(b"\xff\xff\xff\xff")
|
|
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
# the request must still succeed, with the prompt fully re-processed
|
|
res = make_prompt_request(PROMPT_A)
|
|
time.sleep(0.5)
|
|
assert "__TEST_TAG_CACHE_DISK_HIT__" not in log.drain()
|
|
assert res.body["timings"]["prompt_n"] > 1
|
|
|
|
# the corrupt file was deleted
|
|
assert len(kvc_files()) == 0
|
|
|
|
|
|
IMG_URL_CAT = "https://huggingface.co/ggml-org/tinygemma3-GGUF/resolve/main/test/91_cat.png"
|
|
|
|
|
|
def _get_img_base64(url: str) -> str:
|
|
response = requests.get(url)
|
|
response.raise_for_status()
|
|
return base64.b64encode(response.content).decode("utf-8")
|
|
|
|
|
|
@pytest.fixture
|
|
def mmproj_server():
|
|
global cache_dir
|
|
os.environ['LLAMA_MEDIA_MARKER'] = '<__media__>'
|
|
mm_server = ServerPreset.tinygemma3()
|
|
mm_server.n_slots = 1
|
|
mm_server.temperature = 0.0
|
|
mm_server.debug = True
|
|
# use the full SWA cache so the restored image prefix can be reused
|
|
mm_server.swa_full = True
|
|
mm_server.cache_disk = cache_dir
|
|
mm_server.cache_disk_write_through = True
|
|
fd, mm_server.log_path = tempfile.mkstemp(suffix='.log')
|
|
os.close(fd)
|
|
return mm_server
|
|
|
|
|
|
def test_image_prompt_across_restart(mmproj_server):
|
|
server = mmproj_server
|
|
server.start()
|
|
|
|
prompt_cat = {
|
|
"prompt_string": "What is this: <__media__>\n",
|
|
"multimodal_data": [_get_img_base64(IMG_URL_CAT)],
|
|
}
|
|
|
|
res = server.make_request("POST", "/completions", data={
|
|
"n_predict": 0,
|
|
"cache_prompt": True,
|
|
"prompt": prompt_cat,
|
|
})
|
|
assert res.status_code == 200
|
|
prompt_n_full = res.body["timings"]["prompt_n"]
|
|
|
|
res = server.make_request("POST", "/completions", data={
|
|
"n_predict": 0,
|
|
"cache_prompt": True,
|
|
"prompt": "The quick brown fox",
|
|
})
|
|
assert res.status_code == 200
|
|
assert len(kvc_files()) == 1
|
|
|
|
server.stop()
|
|
server.start()
|
|
log = LogReader(server.log_path)
|
|
|
|
# the image KV must be restored from disk in the new process
|
|
res = server.make_request("POST", "/completions", data={
|
|
"n_predict": 0,
|
|
"cache_prompt": True,
|
|
"prompt": prompt_cat,
|
|
})
|
|
assert res.status_code == 200
|
|
assert log.wait_for("__TEST_TAG_CACHE_DISK_HIT__")
|
|
assert res.body["timings"]["prompt_n"] == 1
|
|
assert res.body["timings"]["cache_n"] == prompt_n_full - 1
|