unsloth/tests/test_past_kv_utils.py
Daniel Han b98b1b64f6 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.
2026-03-12 23:10:59 +00:00

157 lines
6.1 KiB
Python

"""
Unit tests for past_key_values utilities.
Self-contained — does NOT import unsloth, so runs without a GPU.
Run with:
python -m pytest tests/test_past_kv_utils.py -v
"""
import unittest
import torch
from transformers.cache_utils import DynamicCache, Cache
# ── Inline copies of the functions under test ──────────────────────────
# These match the implementations of _ensure_cache_is_dynamic and
# _slice_position_ids in unsloth/models/llama.py.
# Kept inline so the test suite can run on any machine (no GPU needed).
def _ensure_cache_is_dynamic(past_key_values):
"""Convert list/tuple of (K, V) pairs to DynamicCache for transformers v5 compat."""
if past_key_values is None:
return None
if isinstance(past_key_values, Cache):
return past_key_values
if isinstance(past_key_values, (tuple, list)) and len(past_key_values) > 0:
cache = DynamicCache()
for layer_idx, layer_kv in enumerate(past_key_values):
cache.update(layer_kv[0], layer_kv[1], layer_idx)
return cache
return past_key_values
def _slice_position_ids(position_ids, input_ids):
"""Slice position_ids to match input_ids length if needed."""
if position_ids is None:
return None
if position_ids.dim() == 2:
if position_ids.shape[1] > input_ids.shape[1]:
position_ids = position_ids[:, -input_ids.shape[1] :]
elif position_ids.dim() == 1:
if position_ids.shape[0] > input_ids.shape[1]:
position_ids = position_ids[-input_ids.shape[1] :]
return position_ids
# ── Tests ──────────────────────────────────────────────────────────────
class TestEnsureCacheIsDynamic(unittest.TestCase):
"""Tests for _ensure_cache_is_dynamic conversion utility."""
def test_none_passthrough(self):
self.assertIsNone(_ensure_cache_is_dynamic(None))
def test_dynamic_cache_passthrough(self):
cache = DynamicCache()
k = torch.randn(1, 4, 8, 16)
v = torch.randn(1, 4, 8, 16)
cache.update(k, v, 0)
result = _ensure_cache_is_dynamic(cache)
self.assertIs(result, cache)
def test_tuple_conversion(self):
"""Tuple of (K, V) pairs should be converted to DynamicCache."""
n_layers = 3
layers = []
for _ in range(n_layers):
k = torch.randn(1, 4, 8, 16)
v = torch.randn(1, 4, 8, 16)
layers.append((k, v))
past_kv = tuple(layers)
result = _ensure_cache_is_dynamic(past_kv)
self.assertIsInstance(result, DynamicCache)
for i in range(n_layers):
cached_k, cached_v = result[i]
self.assertTrue(torch.equal(cached_k, layers[i][0]))
self.assertTrue(torch.equal(cached_v, layers[i][1]))
def test_list_conversion(self):
"""List of (K, V) pairs should be converted to DynamicCache."""
layers = [(torch.randn(1, 4, 8, 16), torch.randn(1, 4, 8, 16))]
result = _ensure_cache_is_dynamic(layers)
self.assertIsInstance(result, DynamicCache)
cached_k, cached_v = result[0]
self.assertTrue(torch.equal(cached_k, layers[0][0]))
def test_empty_tuple_passthrough(self):
result = _ensure_cache_is_dynamic(())
self.assertEqual(result, ())
def test_empty_list_passthrough(self):
result = _ensure_cache_is_dynamic([])
self.assertEqual(result, [])
def test_seq_length_preserved(self):
"""Verify DynamicCache reports correct sequence length after conversion."""
seq_len = 42
layers = [(torch.randn(1, 4, seq_len, 16), torch.randn(1, 4, seq_len, 16))]
result = _ensure_cache_is_dynamic(tuple(layers))
self.assertEqual(result.get_seq_length(), seq_len)
class TestSlicePositionIds(unittest.TestCase):
"""Tests for _slice_position_ids utility."""
def test_none_passthrough(self):
input_ids = torch.zeros(1, 5, dtype = torch.long)
self.assertIsNone(_slice_position_ids(None, input_ids))
def test_2d_no_slice_needed(self):
input_ids = torch.zeros(1, 10, dtype = torch.long)
position_ids = torch.arange(10).unsqueeze(0)
result = _slice_position_ids(position_ids, input_ids)
self.assertTrue(torch.equal(result, position_ids))
def test_2d_slice_needed(self):
"""position_ids longer than input_ids — should take last N."""
input_ids = torch.zeros(1, 3, dtype = torch.long)
position_ids = torch.arange(10).unsqueeze(0) # shape (1, 10)
result = _slice_position_ids(position_ids, input_ids)
self.assertEqual(result.shape, (1, 3))
expected = torch.tensor([[7, 8, 9]])
self.assertTrue(torch.equal(result, expected))
def test_1d_no_slice_needed(self):
input_ids = torch.zeros(1, 5, dtype = torch.long)
position_ids = torch.arange(5)
result = _slice_position_ids(position_ids, input_ids)
self.assertTrue(torch.equal(result, position_ids))
def test_1d_slice_needed(self):
input_ids = torch.zeros(1, 3, dtype = torch.long)
position_ids = torch.arange(10) # shape (10,)
result = _slice_position_ids(position_ids, input_ids)
self.assertEqual(result.shape, (3,))
expected = torch.tensor([7, 8, 9])
self.assertTrue(torch.equal(result, expected))
def test_shorter_position_ids_passthrough(self):
"""position_ids shorter than input_ids — should pass through unchanged."""
input_ids = torch.zeros(1, 10, dtype = torch.long)
position_ids = torch.arange(5).unsqueeze(0)
result = _slice_position_ids(position_ids, input_ids)
self.assertTrue(torch.equal(result, position_ids))
def test_exact_match(self):
"""Exact same length — no slicing."""
input_ids = torch.zeros(2, 7, dtype = torch.long)
position_ids = torch.arange(7).unsqueeze(0).expand(2, -1)
result = _slice_position_ids(position_ids, input_ids)
self.assertEqual(result.shape, (2, 7))
if __name__ == "__main__":
unittest.main()