unsloth/scripts/kv_cache_multiturn_benchmark.py
Daniel Han 0dae825e69
Fix: Support past_key_values in model.generate for multi-turn conversations (#4232)
* Add transformers v5 compat, Qwen3/Gemma2 past_key_values support, and shared utilities

- Add _ensure_cache_is_dynamic to convert tuple/list KV caches to DynamicCache
  before transformers v5's _get_cache rejects them
- Wrap generate() via fix_prepare_inputs_for_generation for all model types
- Fix Qwen3 RoPE: index cos/sin by position_ids (both branches were identical)
- Fix Gemma2 softcapping attention: handle 4D masks and Q_len != K_len during
  prefill with past_key_values
- Add _slice_position_ids shared utility, replace inline duplication in
  PeftModel_fast_forward, MistralForCausalLM_fast_forward, and
  CausalLM_fast_forward (covers Llama/Qwen3/Gemma2)
- Remove redundant seq_len assignment in _fast_prepare_inputs_for_generation
- Add unit tests (test_past_kv_utils.py) and GPU integration tests
  (test_past_kv_models.py) for Llama, Qwen3, and Gemma2

* Add multi-turn KV cache benchmark and model-specific integration tests

- Add examples/kv_cache_multiturn_benchmark.py: realistic 15-turn tech
  support conversation (~1500 history tokens) with scaling test at 4, 8,
  12, and 16 messages to show speedup increasing with history length
- Add tests/test_past_kv_models.py: GPU integration tests for Llama,
  Qwen3, and Gemma2 past_key_values generation plus tuple-KV v5 compat
- Add tests/test_past_kv_utils.py: CPU-only unit tests for
  _ensure_cache_is_dynamic and _slice_position_ids utilities

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix past_key_values support in model.generate for PR #4232

Core changes that were missing from the original PR:

1. unsloth/models/llama.py:
   - Add _ensure_cache_is_dynamic() to convert tuple/list KV caches
     to DynamicCache for transformers v5 compatibility
   - Add _slice_position_ids() to handle position_ids slicing when
     input_ids is trimmed to uncached tokens
   - Fix unsloth_fast_generate: skip setting cache_implementation
     when user provides past_key_values (avoids ValueError on
     transformers >= 4.57)
   - Fix _fast_prepare_inputs_for_generation: when past_key_values
     covers fewer tokens than input_ids, keep only the uncached
     portion instead of always slicing to last token
   - Fix CausalLM_fast_forward: add input_ids.shape[1] == 1 guard
     so multi-token prefill with external KV cache falls through
     to the regular model forward path

2. unsloth/models/mistral.py:
   - Same input_ids.shape[1] == 1 guard and multi-token fallback
     as CausalLM_fast_forward

3. tests/test_past_kv_models.py:
   - Extract model loading into _load_model() with proper SkipTest
     handling (only skips on loading errors, not on test failures)
   - Remove blanket try/except that was masking real failures
   - Remove exact output match assertion (4-bit quantization with
     different computation paths can produce slightly different but
     equally valid outputs)

4. tests/test_past_kv_utils.py:
   - Fix comment to accurately describe the inlined functions

Tested: Llama-3.2-1B-Instruct, Qwen3-0.6B, gemma-2-2b-it
All 4 integration tests pass, 14 unit tests pass, benchmark runs,
and standard training (Gemma2, Llama) is not regressed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add license headers to the past_key_values tests and benchmark

* Continue a direct forward onto a cache after the cached tokens

A multi-token model(input_ids, past_key_values=cache) call without position_ids
or attention_mask restarted RoPE at position 0 (34-39% logit error vs the full
forward on Llama-3.2-1B), and on xFormers ran with no causal bias. Default both
to continue after the cache as transformers does, convert Cache objects in the
forwards too, and drop the xFormers guard that generate() never reached.

* Feed a suffix-only new turn and flatten static caches to their filled length

transformers 5 generate() accepts only the new tokens in input_ids when the
attention mask spans cache + new; the prefill kept only the last of them. Use
the 2D mask length minus the cached length as the number of tokens to feed,
which is 1 on every ordinary decode step.

StaticCache layers hold max_cache_len positions, so the legacy tuple now keeps
only the filled prefix. A user cache also sets disable_compile, since
transformers' CUDA-graph auto-compile for static caches overwrites the decode
kernels' reused buffers.

* Reject cache layers that do not hold every cached position

A QuantizedLayer counts every token but keeps only the unquantized tail in
.keys, and a sliding-window layer drops old positions; flattening either
silently lost history. Raise a clear error instead.

* Drop leftover decode buffers when generate() gets a user cache

The fast decode path seeds its KV buffers only when they are missing, and a
user cache with one uncached token goes straight to it, so a saved cache
reused after another generate() attended to that run's KV (Llama-3.2-1B
answered '!!!!...'). Clear the buffers at the start of a user-cache generate.

* Size the cached-decode mask to the flattened cache, not max_cache_len

A partially filled StaticCache is flattened to its filled length, but its
max_cache_len still set the 4D mask's key length, which mismatches the KV
concatenated in attention on transformers that build that mask (<= 4.52).

* Reject user caches on FastFalconH1Model with a clear error

FastFalconH1Model keeps its own input preparation for the hybrid Mamba cache,
which does not handle a user cache here; say so instead of failing inside it.
FalconH1 loaded through FastLanguageModel takes the generic path and is
unaffected.

* Trim comments

---------

Co-authored-by: vivekkalyanarangan30 <vivekkalyanarangan30@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-09-29 11:59:43 -07:00

84 lines
3.3 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
"""Multi-turn generate(): re-encode the whole conversation vs continue from a KV cache of the history.
python scripts/kv_cache_multiturn_benchmark.py --model unsloth/Llama-3.2-1B-Instruct --turns 4 8 16 32
"""
import argparse
import time
from unsloth import FastLanguageModel
import torch
def timed(fn, runs):
times = []
for _ in range(runs):
torch.cuda.synchronize()
t0 = time.perf_counter()
out = fn()
torch.cuda.synchronize()
times.append(time.perf_counter() - t0)
return out, sorted(times)[len(times) // 2]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", default = "unsloth/Llama-3.2-1B-Instruct")
parser.add_argument("--turns", type = int, nargs = "+", default = [4, 8, 16, 32])
parser.add_argument("--max-new-tokens", type = int, default = 32)
parser.add_argument("--runs", type = int, default = 5)
parser.add_argument("--load-in-4bit", action = "store_true")
args = parser.parse_args()
model, tokenizer = FastLanguageModel.from_pretrained(
args.model, max_seq_length = 8192, load_in_4bit = args.load_in_4bit
)
FastLanguageModel.for_inference(model)
gen = dict(
max_new_tokens = args.max_new_tokens, min_new_tokens = args.max_new_tokens, do_sample = False
)
print(
"| turns | history tok | new tok | full re-encode s | from cache s | speedup | tokens match |"
)
print("|---:|---:|---:|---:|---:|---:|:---:|")
for turns in args.turns:
history = []
for i in range(turns):
history.append(
{
"role": "user",
"content": f"Fact {i}: item {i} costs {3 * i + 7} dollars. Please remember it.",
}
)
history.append(
{"role": "assistant", "content": f"Noted: item {i} costs {3 * i + 7} dollars."}
)
question = [{"role": "user", "content": "What does item 2 cost?"}]
text_h = tokenizer.apply_chat_template(history, tokenize = False)
text_f = tokenizer.apply_chat_template(
history + question, tokenize = False, add_generation_prompt = True
)
enc = lambda t: tokenizer(t, return_tensors = "pt", add_special_tokens = False).to("cuda")
hist, full = enc(text_h), enc(text_f)
n, L = hist.input_ids.shape[1], full.input_ids.shape[1]
if not torch.equal(full.input_ids[:, :n], hist.input_ids):
raise SystemExit("chat template does not keep the history as a token prefix")
with torch.no_grad():
cache = model(**hist, use_cache = True).past_key_values
model.generate(**full, max_new_tokens = 2)
model.generate(**full, past_key_values = cache, max_new_tokens = 2)
base, t_base = timed(lambda: model.generate(**full, **gen), args.runs)
kv, t_kv = timed(lambda: model.generate(**full, past_key_values = cache, **gen), args.runs)
match = torch.equal(base[0, L:], kv[0, L:])
print(
f"| {turns} | {n} | {L - n} | {t_base:.4f} | {t_kv:.4f} | {t_base / t_kv:.2f}x | {'YES' if match else 'NO'} |"
)
if __name__ == "__main__":
main()