mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-21 05:11:50 +00:00
* 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>
84 lines
3.3 KiB
Python
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()
|