Skip to content

Commit 173fa12

Browse files
Ankur Kaulabetlen
authored andcommitted
fix: preserve recurrent/hybrid model state when the full prompt is already cached
1 parent b11fe07 commit 173fa12

3 files changed

Lines changed: 279 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- fix: preserve recurrent/hybrid model state when the full prompt is already cached by @allthatido in #2306
11+
1012
## [0.3.31]
1113

1214
- feat: update llama.cpp to ggml-org/llama.cpp@f449e0553

llama_cpp/llama.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,8 @@ def free_lora_adapter():
471471
self._candidates = internals.LlamaTokenDataArray(n_vocab=self._n_vocab)
472472

473473
self.n_tokens = 0
474+
# Restored or truncated state must decode before sampling.
475+
self._requires_eval = True
474476
self.input_ids: npt.NDArray[np.intc] = np.ndarray((n_ctx,), dtype=np.intc)
475477
self.scores: npt.NDArray[np.single] = np.ndarray(
476478
(n_ctx if logits_all == True else n_batch, self._n_vocab), dtype=np.single
@@ -647,6 +649,7 @@ def set_seed(self, seed: int):
647649
def reset(self):
648650
"""Reset the model state."""
649651
self.n_tokens = 0
652+
self._requires_eval = True
650653

651654
if self._is_recurrent or self._is_hybrid:
652655
mem = llama_cpp.llama_get_memory(self._ctx.ctx)
@@ -689,6 +692,7 @@ def eval(self, tokens: Sequence[int]):
689692
pass
690693
# Update n_tokens
691694
self.n_tokens += n_tokens
695+
self._requires_eval = False
692696

693697
def _init_sampler(
694698
self,
@@ -900,24 +904,29 @@ def generate(
900904
grammar=grammar,
901905
)
902906

907+
tokens = list(tokens)
908+
903909
# Check for kv cache prefix match
904910
if reset and self.n_tokens > 0:
911+
full_prompt_cached = (
912+
len(tokens) > 0
913+
and self.n_tokens == len(tokens)
914+
and self._input_ids[: self.n_tokens].tolist() == tokens
915+
)
905916
longest_prefix = 0
906917
for a, b in zip(self._input_ids, tokens[:-1]):
907918
if a == b:
908919
longest_prefix += 1
909920
else:
910921
break
911922

912-
# Recurrent and hybrid models cannot rewind state; reset if needed
913-
if (
914-
self._is_recurrent or self._is_hybrid
915-
) and longest_prefix < self.n_tokens:
923+
if full_prompt_cached and not self._requires_eval:
924+
reset = False
925+
tokens = []
916926
longest_prefix = 0
917-
reset = True
918927
if self.verbose:
919928
print(
920-
"Llama.generate: recurrent/hybrid model requires full state reset",
929+
"Llama.generate: full prompt already cached, skipping reset",
921930
file=sys.stderr,
922931
)
923932

@@ -926,6 +935,7 @@ def generate(
926935
reset = False
927936
tokens = tokens[longest_prefix:]
928937
self.n_tokens = longest_prefix
938+
self._requires_eval = True
929939
if self.verbose:
930940
print(
931941
f"Llama.generate: {longest_prefix} prefix-match hit, "
@@ -948,7 +958,6 @@ def generate(
948958
# grammar.reset()
949959

950960
sample_idx = self.n_tokens + len(tokens) - 1
951-
tokens = list(tokens)
952961

953962
# Eval and sample
954963
while True:
@@ -988,6 +997,7 @@ def generate(
988997
if sample_idx < self.n_tokens and token != self._input_ids[sample_idx]:
989998
self.n_tokens = sample_idx
990999
self._ctx.kv_cache_seq_rm(-1, self.n_tokens, -1)
1000+
self._requires_eval = True
9911001
break
9921002

9931003
if self.draft_model is not None:
@@ -2217,6 +2227,7 @@ def load_state(self, state: LlamaState) -> None:
22172227
rest[rest > 0] = 0.0
22182228
self.input_ids = state.input_ids.copy()
22192229
self.n_tokens = state.n_tokens
2230+
self._requires_eval = True
22202231
self._seed = state.seed
22212232
state_size = state.llama_state_size
22222233
LLamaStateArrayType = ctypes.c_uint8 * state_size

tests/test_llama.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ctypes
2+
import itertools
23
import multiprocessing
34

45
import numpy as np
@@ -64,6 +65,14 @@ def llama_cpp_model_path():
6465
return model_path
6566

6667

68+
@pytest.fixture
69+
def llama_cpp_transformer_model_path():
70+
repo_id = "ggml-org/models"
71+
filename = "tinyllamas/stories15M-q4_0.gguf"
72+
model_path = hf_hub_download(repo_id, filename)
73+
return model_path
74+
75+
6776
@pytest.fixture
6877
def llama_cpp_embedding_model_path():
6978
repo_id = "CompendiumLabs/bge-small-en-v1.5-gguf"
@@ -339,6 +348,256 @@ def test_hybrid_model_prompt_cache_reset(llama_cpp_hybrid_model_path):
339348
)
340349

341350

351+
def _create_test_model(model_path):
352+
return llama_cpp.Llama(
353+
model_path,
354+
n_ctx=64,
355+
n_batch=64,
356+
n_ubatch=64,
357+
n_threads=multiprocessing.cpu_count(),
358+
n_threads_batch=multiprocessing.cpu_count(),
359+
logits_all=False,
360+
verbose=False,
361+
)
362+
363+
364+
def _generate_test_tokens(model, tokens, max_tokens=3):
365+
return list(
366+
itertools.islice(
367+
model.generate(
368+
tokens,
369+
temp=0.0,
370+
),
371+
max_tokens,
372+
)
373+
)
374+
375+
376+
def _poison_logits_at_prompt_length(model, tokens, expected_next_token):
377+
replacement_tokens = (
378+
model.token_eos(),
379+
model.token_nl(),
380+
0,
381+
1,
382+
2,
383+
model.n_vocab() - 1,
384+
)
385+
386+
for replacement_token in replacement_tokens:
387+
poison_tokens = list(tokens)
388+
poison_tokens[-1] = replacement_token
389+
if poison_tokens == tokens:
390+
continue
391+
392+
model.reset()
393+
model.eval(poison_tokens)
394+
if model.sample(temp=0.0, idx=len(tokens) - 1) != expected_next_token:
395+
return poison_tokens
396+
397+
raise AssertionError("failed to find a same-length poison prompt")
398+
399+
400+
def _assert_exact_cached_prompt_reuse_matches_fresh(
401+
model_path,
402+
*,
403+
is_recurrent: bool,
404+
is_hybrid: bool,
405+
):
406+
prompt = "The quick brown fox"
407+
fresh = _create_test_model(model_path)
408+
tokens = fresh.tokenize(prompt.encode(), add_bos=True, special=True)
409+
410+
assert fresh._is_recurrent is is_recurrent
411+
assert fresh._is_hybrid is is_hybrid
412+
413+
expected_tokens = _generate_test_tokens(fresh, tokens)
414+
415+
cached = _create_test_model(model_path)
416+
assert cached._is_recurrent is is_recurrent
417+
assert cached._is_hybrid is is_hybrid
418+
419+
cached.eval(tokens)
420+
assert cached.n_tokens == len(tokens)
421+
assert cached.input_ids[: cached.n_tokens].tolist() == tokens
422+
assert cached.sample(temp=0.0, idx=len(tokens) - 1) == expected_tokens[0]
423+
424+
reset_calls = 0
425+
original_reset = cached.reset
426+
427+
def reset_tracker():
428+
nonlocal reset_calls
429+
reset_calls += 1
430+
original_reset()
431+
432+
cached.reset = reset_tracker
433+
434+
cached_tokens = _generate_test_tokens(cached, tokens)
435+
assert reset_calls == 0
436+
assert cached_tokens == expected_tokens
437+
assert cached.n_tokens == len(tokens) + len(cached_tokens) - 1
438+
439+
440+
def _assert_loaded_exact_cached_prompt_reuse_matches_fresh(
441+
model_path,
442+
*,
443+
is_recurrent: bool,
444+
is_hybrid: bool,
445+
):
446+
prompt = "The quick brown fox"
447+
fresh = _create_test_model(model_path)
448+
tokens = fresh.tokenize(prompt.encode(), add_bos=True, special=True)
449+
expected_tokens = _generate_test_tokens(fresh, tokens)
450+
451+
source = _create_test_model(model_path)
452+
assert source._is_recurrent is is_recurrent
453+
assert source._is_hybrid is is_hybrid
454+
455+
source.eval(tokens)
456+
state = source.save_state()
457+
458+
loaded = _create_test_model(model_path)
459+
assert loaded._is_recurrent is is_recurrent
460+
assert loaded._is_hybrid is is_hybrid
461+
462+
poison_tokens = _poison_logits_at_prompt_length(
463+
loaded,
464+
tokens,
465+
expected_tokens[0],
466+
)
467+
assert len(poison_tokens) == len(tokens)
468+
loaded.load_state(state)
469+
470+
assert loaded.n_tokens == len(tokens)
471+
assert loaded.input_ids[: loaded.n_tokens].tolist() == tokens
472+
473+
loaded_tokens = _generate_test_tokens(loaded, tokens)
474+
assert loaded_tokens == expected_tokens
475+
assert loaded.n_tokens == len(tokens) + len(loaded_tokens) - 1
476+
477+
478+
def _assert_ram_cache_exact_prompt_hit_matches_fresh(
479+
model_path,
480+
*,
481+
is_recurrent: bool,
482+
is_hybrid: bool,
483+
):
484+
prompt = "The quick brown fox"
485+
fresh = _create_test_model(model_path)
486+
tokens = fresh.tokenize(prompt.encode(), add_bos=True, special=True)
487+
expected = fresh.create_completion(
488+
tokens,
489+
max_tokens=1,
490+
temperature=0.0,
491+
seed=1337,
492+
)
493+
494+
cache = llama_cpp.LlamaRAMCache()
495+
writer = _create_test_model(model_path)
496+
writer.set_cache(cache)
497+
writer.create_completion(
498+
tokens,
499+
max_tokens=1,
500+
temperature=0.0,
501+
seed=1337,
502+
)
503+
504+
cached = _create_test_model(model_path)
505+
assert cached._is_recurrent is is_recurrent
506+
assert cached._is_hybrid is is_hybrid
507+
cached.set_cache(cache)
508+
509+
load_state_calls = 0
510+
original_load_state = cached.load_state
511+
512+
def load_state_tracker(state):
513+
nonlocal load_state_calls
514+
load_state_calls += 1
515+
original_load_state(state)
516+
517+
cached.load_state = load_state_tracker
518+
519+
actual = cached.create_completion(
520+
tokens,
521+
max_tokens=1,
522+
temperature=0.0,
523+
seed=1337,
524+
)
525+
526+
assert load_state_calls == 1
527+
assert actual["choices"][0]["text"] == expected["choices"][0]["text"]
528+
assert actual["usage"]["completion_tokens"] == expected["usage"]["completion_tokens"]
529+
530+
531+
def test_transformer_model_exact_cached_prompt_reuse_matches_fresh(
532+
llama_cpp_transformer_model_path,
533+
):
534+
_assert_exact_cached_prompt_reuse_matches_fresh(
535+
llama_cpp_transformer_model_path,
536+
is_recurrent=False,
537+
is_hybrid=False,
538+
)
539+
540+
541+
def test_recurrent_model_exact_cached_prompt_reuse_matches_fresh(
542+
llama_cpp_recurrent_model_path,
543+
):
544+
_assert_exact_cached_prompt_reuse_matches_fresh(
545+
llama_cpp_recurrent_model_path,
546+
is_recurrent=True,
547+
is_hybrid=False,
548+
)
549+
550+
551+
def test_hybrid_model_exact_cached_prompt_reuse_matches_fresh(
552+
llama_cpp_hybrid_model_path,
553+
):
554+
_assert_exact_cached_prompt_reuse_matches_fresh(
555+
llama_cpp_hybrid_model_path,
556+
is_recurrent=False,
557+
is_hybrid=True,
558+
)
559+
560+
561+
def test_recurrent_model_loaded_exact_cached_prompt_reuse_matches_fresh(
562+
llama_cpp_recurrent_model_path,
563+
):
564+
_assert_loaded_exact_cached_prompt_reuse_matches_fresh(
565+
llama_cpp_recurrent_model_path,
566+
is_recurrent=True,
567+
is_hybrid=False,
568+
)
569+
570+
571+
def test_hybrid_model_loaded_exact_cached_prompt_reuse_matches_fresh(
572+
llama_cpp_hybrid_model_path,
573+
):
574+
_assert_loaded_exact_cached_prompt_reuse_matches_fresh(
575+
llama_cpp_hybrid_model_path,
576+
is_recurrent=False,
577+
is_hybrid=True,
578+
)
579+
580+
581+
def test_recurrent_model_ram_cache_exact_prompt_hit_matches_fresh(
582+
llama_cpp_recurrent_model_path,
583+
):
584+
_assert_ram_cache_exact_prompt_hit_matches_fresh(
585+
llama_cpp_recurrent_model_path,
586+
is_recurrent=True,
587+
is_hybrid=False,
588+
)
589+
590+
591+
def test_hybrid_model_ram_cache_exact_prompt_hit_matches_fresh(
592+
llama_cpp_hybrid_model_path,
593+
):
594+
_assert_ram_cache_exact_prompt_hit_matches_fresh(
595+
llama_cpp_hybrid_model_path,
596+
is_recurrent=False,
597+
is_hybrid=True,
598+
)
599+
600+
342601
def test_real_llama_embeddings(llama_cpp_embedding_model_path):
343602
model = llama_cpp.Llama(
344603
llama_cpp_embedding_model_path,

0 commit comments

Comments
 (0)