KV Cache: почему LLM тормозит при длинном контексте и как ускорить
Введение: почему ответ LLM замедляется со временем?
Вы общаетесь с локальной LLM. Первые токены появляются быстро — 50-100 токенов/сек. Но чем длиннее диалог, тем медленнее модель отвечает. После 2000 токенов контекста скорость падает до 10-15 токенов/сек. После 8000 — и того меньше.
Почему так происходит? Проблема не в вашей видеокарте и не в настройках. Проблема в KV Cache — структуре данных, которую каждая LLM вынуждена хранить и обрабатывать для каждого нового токена.
В этой статье разберём:
- Что такое KV Cache и зачем он нужен
- Почему он съедает память и замедляет генерацию
- Как vLLM решает эту проблему через PagedAttention
- Какие техники оптимизации существуют
- Как подобрать context length под ваше железо
Проблема: почему трансформер замедляется с ростом контекста?
Авто регрессионная генерация
LLM генерирует текст по одному токену. Каждый шаг — это forward pass через всю сеть:
Шаг 1: [Привет] → "Как"
Шаг 2: [Привет, Как] → "дела"
Шаг 3: [Привет, Как, дела] → "?"
Шаг 4: [Привет, Как, дела, ?] → "..."
...
Каждый forward pass проходит через все слои трансформера. В 7B модели — 32 слоя. В 70B — 80 слоев.
Attention mechanism: O(n²) сложность
Ключевая операция в трансформере — self-attention. Каждый токен "смотрит" на все предыдущие токены:
Контекст из 100 токенов:
Токен 1 → attention на 1 токен
Токен 2 → attention на 2 токена
...
Токен 100 → attention на 100 токенов
Всего attention вычислений: 1 + 2 + ... + 100 = 5050
Формула self-attention:
Attention(Q, K, V) = softmax(Q @ K^T / √d) @ V
Где:
- Q (Query) — запрос, что ищем
- K (Key) — ключ, по чему ищем
- V (Value) — значение, что возвращаем
Размеры матриц зависят от длины контекста (n) и размерности модели (d):
- Q: (n × d)
- K: (n × d)
- V: (n × d)
- Q @ K^T: (n × n) ← квадратичная сложность!
n = 1000 токенов: матрица attention 1000 × 1000 = 1M элементов
n = 8000 токенов: матрица attention 8000 × 8000 = 64M элементов
n = 128000 токенов: матрица attention 128000 × 128000 = 16.4M элементов
KV Cache: память вместо пересчёта
Чтобы не пересчитывать K и V для каждого нового токена, LLM кэширует их:
Шаг 1: вычисляем K₁, V₁ → сохраняем в cache
Шаг 2: вычисляем K₂, V₂ → сохраняем в cache. Используем K₁, V₁ из cache
Шаг 3: вычисляем K₃, V₃ → сохраняем в cache. Используем K₁, K₂, V₁, V₂ из cache
...
Шаг n: вычисляем Kₙ, Vₙ → используем K₁..Kₙ₋₁, V₁..Vₙ₋₁ из cache
KV Cache растёт линейно с длиной контекста. И это растёт очень быстро:
Qwen 2.5 7B (d = 4096, layers = 32):
Context 4K: 4K × 2 × 32 × 2 bytes (FP16) = 512 KB
Context 8K: 8K × 2 × 32 × 2 bytes = 1 MB
Context 32K: 32K × 2 × 32 × 2 bytes = 4 MB
Context 128K: 128K × 2 × 32 × 2 bytes = 16 MB
Context 1M: 1M × 2 × 32 × 2 bytes = 128 MB
Подождите, 128 MB — это немного? Для одной модели — да. Но:
Реальная память на GPU:
Qwen 2.5 7B Q4 (квантованная):
Вес модели: ~4 GB
KV Cache (32K): ~4 MB
Итого: ~4 GB — кэш мал по сравнению с весами
Qwen 2.5 72B Q4:
Вес модели: ~36 GB
KV Cache (32K): ~32 MB
KV Cache (128K): ~128 MB
На A100 80GB — нормально
Qwen 2.5 72B FP16:
Вес модели: ~144 GB
KV Cache (32K): ~64 MB
Не влезает даже на 2×H100 только из-за весов!
Главная проблема KV Cache — не размер, а fragmentation (фрагментация). Когда вы запускаете несколько запросов параллельно, каждый получает свой KV Cache. Свободная память есть, но она разбросана — как в классической операционной системе с фрагментацией памяти.
Теория: как устроен KV Cache
Структура данных
# Псевдокод структуры KV Cache
class KVCache:
# Для каждого слоя трансформера
keys: Tensor[layer][seq_len, head_dim] # K — ключи
values: Tensor[layer][seq_len, head_dim] # V — значения
# Текущая позиция (сколько токенов уже в кэше)
current_len: int
# Максимальная длина (контекстное окно)
max_len: int
def add(token_idx, k, v):
"""Добавляем новый токен в кэш"""
self.keys[:, self.current_len] = k
self.values[:, self.current_len] = v
self.current_len += 1
def get_all():
"""Получаем все K и V для attention"""
return self.keys[:, :self.current_len], self.values[:, :self.current_len]
Attention с KV Cache
# Без KV Cache — пересчитываем всё каждый раз
def attention_without_cache(prompt, new_token):
tokens = prompt + [new_token]
Q = model.W_q(tokens)
K = model.W_k(tokens) # пересчитываем K для ВСЕХ токенов!
V = model.W_v(tokens) # пересчитываем V для ВСЕХ токенов!
return softmax(Q @ K.T / sqrt(d)) @ V
# С KV Cache — используем кэш
def attention_with_cache(prompt, new_token, kv_cache):
tokens = prompt + [new_token]
Q_new = model.W_q([new_token]) # только для нового токена!
K_new, V_new = model.W_k([new_token]), model.W_v([new_token])
kv_cache.add(K_new, V_new) # сохраняем в кэш
K_all, V_all = kv_cache.get_all() # берём из кэша
return softmax(Q_new @ K_all.T / sqrt(d)) @ V_all
Ключевое отличие: без кэша — O(n × d²) на каждый токен. С кэшем — O(d²) на каждый токен (только для нового), но O(n × d) на attention.
Память: подробный расчёт
KV Cache memory = num_layers × seq_len × 2 × num_heads × head_dim × bytes_per_token
Для Qwen 2.5 7B:
num_layers = 32
seq_len = 32768 (32K)
num_heads = 32
head_dim = 128 (4096 / 32)
bytes_per_token = 2 (FP16)
KV Cache = 32 × 32768 × 2 × 32 × 128 × 2
= 32 × 32768 × 16384
= 17,179,869,184 bytes
≈ 16 MB
Для Qwen 2.5 72B:
num_layers = 80
seq_len = 131072 (128K)
num_heads = 64
head_dim = 128 (8192 / 64)
bytes_per_token = 2 (FP16)
KV Cache = 80 × 131072 × 2 × 64 × 128 × 2
= 17,179,869,184 bytes
≈ 128 MB
Важно: при квантовании KV Cache обычно остаётся в FP16, потому что:
- Attention чувствителен к точности
- KV Cache — меньшая часть памяти по сравнению с весами
- Квантование KV Cache даёт минимальный эффект
Проблемы KV Cache
Проблема 1: Fragmentation (фрагментация памяти)
Самая большая проблема при параллельных запросах.
GPU Memory (24 GB на RTX 4090):
Загрузка модели Qwen 7B Q4: 4 GB
Остаток: 20 GB
Попытка запустить 10 параллельных запросов с context=32K:
KV Cache на запрос: 16 MB
10 запросов: 160 MB — вроде мало?
Но память разбита на блоки фиксированного размера.
Если блок = 2 MB, а запросы имеют разную длину:
Блок 1: 1.2 MB использовано, 0.8 MB свободно
Блок 2: 1.8 MB использовано, 0.2 MB свободно
Блок 3: 0.5 MB использовано, 1.5 MB свободно
...
Итого свободно: 20 GB - 160 MB
Но непрерывных блоков для нового запроса: нет!
Аналогия: как парковка. 100 мест свободно, но они разбросаны. Для нового большого автопарка (длинный контекст) непрерывных мест нет.
Проблема 2: Preamble bottleneck (узкое место преамбулы)
Первые токены контекста (системный промпт, история диалога) обрабатываются при каждом новом токене ответа:
Пользователь: "Напиши статью о машинном обучении"
Системный промпт: 500 токенов
История: 1000 токенов
Ответ модели: 512 токенов
При генерации каждого токена ответа:
Attention вычисляется на 500 + 1000 + текущий_ответ токены
= 1500 + 512 = до 2012 токенов в attention
Preamble (500 + 1000 = 1500 токенов) обрабатывается 512 раз!
Время генерации одного токена:
Prefill (системный промпт + история): 1 forward pass, но быстро (batch)
Decode (каждый новый токен): 1 forward pass через весь контекст
При длинном контексте:
Decode step: 80% времени уходит на attention с preamble
20% времени — на feed-forward слои
Проблема 3: Context window — не бесплатно
Context length → Память → Скорость
4K: 16 MB KV Cache, 1x скорость
8K: 32 MB KV Cache, 0.8x скорость
16K: 64 MB KV Cache, 0.5x скорость
32K: 128 MB KV Cache, 0.3x скорость
64K: 256 MB KV Cache, 0.15x скорость
128K: 512 MB KV Cache, 0.08x скорость
Решения: как оптимизировать KV Cache
Решение 1: PagedAttention (vLLM)
vLLM решает проблему фрагментации, используя идеи из операционных систем: страничная адресация.
Обычный KV Cache (continuous):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ R1 │ R1 │ R1 │ R2 │ R2 │ R3 │ R3 │ R3 │
└────┴────┴────┴────┴────┴────┴────┴────┘
R1 = Request 1, R2 = Request 2, ...
Проблемы:
- Каждый запрос занимает непрерывный блок
- Фрагментация при разной длине запросов
- Нельзя разделить блок между запросами
PagedAttention (paged):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│R1:1│R1:1│R2:1│R2:1│R1:2│R3:1│R3:1│R4:1│
├────┼────┼────┼────┼────┼────┼────┼────┤
│R2:2│R3:2│R4:2│R1:3│R1:3│R5:1│R5:1│R2:3│
└────┴────┴────┴────┴────┴────┴────┴────┘
Блоки (pages) фиксированного размера (16-64 токена)
Запросы — это списки блоков, как в OS
Блоки можно переиспользовать и объединять
# Псевдокод PagedAttention
class KVCacheManager:
def __init__(self, block_size=16):
# Каждый блок = 16 токенов
self.block_size = block_size
self.free_blocks = list(range(num_blocks))
self.request_blocks = {} # request_id → [block_indices]
def allocate(self, request_id, num_tokens):
"""Выделяем блоки для запроса"""
num_blocks = ceil(num_tokens / self.block_size)
blocks = [self.free_blocks.pop() for _ in range(num_blocks)]
self.request_blocks[request_id] = blocks
return blocks
def append(self, request_id, tokens):
"""Добавляем токены в кэш"""
blocks = self.request_blocks[request_id]
for token in tokens:
if len(blocks[-1]) < self.block_size:
blocks[-1].append(token)
else:
new_block = self.free_blocks.pop()
blocks.append([token])
def get_attention_inputs(self, request_id):
"""Собираем K и V из не-непрерывных блоков"""
blocks = self.request_blocks[request_id]
K = concat([block.keys for block in blocks])
V = concat([block.values for block in blocks])
return K, V
Результат vLLM:
- GPU utilization: 70-80% (vs 30-40% без paged attention)
- Throughput: в 2-4x выше чем у Hugging Face Transformers
- Memory efficiency: нет фрагментации, блоки переиспользуются
Решение 2: KV Cache Compression
Сжимаем кэш, чтобы освободить память.
2a. KV Cache Eviction (отсечение)
# Удаляем старые токены из контекста
def compress_kv_cache(kv_cache, min_tokens=1000, keep_tokens=2000):
"""
Оставляем только последние keep_tokens токенов
"""
if len(kv_cache) > keep_tokens:
# Удаляем старые токены
evict_count = len(kv_cache) - keep_tokens
kv_cache = kv_cache[evict_count:]
# Или: сжимаем preamble (системный промпт)
if len(kv_cache) > min_tokens:
# Оставляем preamble + последние N токенов
preamble_tokens = kv_cache[:min_tokens]
recent_tokens = kv_cache[-keep_tokens:]
kv_cache = concatenate([preamble_tokens, recent_tokens])
return kv_cache
Когда работает:
- Длинный диалог, где старые сообщения не критичны
- Системный промпт можно сократить
- Recent context важнее historical
Риски:
- Потеря контекста может ухудшить качество ответа
- Модель может "забыть" важную информацию из начала диалога
2b. KV Cache Quantization
Квантуем KV Cache до INT8 или INT4:
# KV Cache в INT8
original_kv_cache: FP16 [layers, seq_len, heads, head_dim]
compressed_kv_cache: INT8 [layers, seq_len, heads, head_dim]
# Сжатие в 2x (FP16 → INT8)
# Без потери качества для attention
Результат:
- FP16 KV Cache: 2 bytes/token/layer
- INT8 KV Cache: 1 byte/token/layer
- INT4 KV Cache: 0.5 byte/token/layer (может снизить качество)
Решение 3: Prefix Caching
Кэшируем KV Cache для повторяющихся частей контекста.
# Пример: несколько запросов с одинаковым системным промптом
system_prompt = "Ты полезный ассистент..." # 500 токенов
# Без prefix caching:
# Запрос 1: compute KV for system_prompt → 500 forward passes
# Запрос 2: compute KV for system_prompt → 500 forward passes
# Запрос 3: compute KV for system_prompt → 500 forward passes
# С prefix caching:
# Запрос 1: compute KV for system_prompt → кэшируем по hash
# Запрос 2: hash(system_prompt) найден в cache → используем готовый
# Запрос 3: hash(system_prompt) найден в cache → используем готовый
class PrefixCache:
def __init__(self):
self.cache = {} # hash(text) → KV Cache blocks
def get_or_compute(self, text, model):
text_hash = hash(text)
if text_hash in self.cache:
# Prefix найден — используем готовый KV Cache
return self.cache[text_hash]
# Вычисляем KV Cache для prefix
kv_cache = compute_kv_cache(text, model)
self.cache[text_hash] = kv_cache
return kv_cache
def evict(self, min_free_blocks):
"""Удаляем редко используемые prefix'ы"""
# LRU eviction: удаляем least recently used
while len(self.free_blocks) < min_free_blocks:
oldest = self.cache.most_recently_used()
self.cache.evict(oldest)
Где применяется:
- vLLM с
enable_prefix_caching=True - Multiple requests с одинаковым system prompt
- API с повторяющимися шаблонами промптов
Эффект: 2-5x ускорение prefill для запросов с повторяющимся prefix.
Решение 4: Flash Attention
Оптимизированный attention, который не хранит полную матрицу attention в памяти.
# Обычный attention:
# Q @ K.T → [seq_len, seq_len] матрица → Memory O(n²)
# Для n=32000: 32000 × 32000 × 4 bytes = 4 GB!
# Flash Attention:
# Разбиваем матрицу на tiles и обрабатываем по частям
# Memory: O(n × d) вместо O(n²)
# Для n=32000, d=128: 32000 × 128 × 4 bytes = 16 MB
Flash Attention 2 оптимизации:
1. Tiled computation — обработка по тайлам
2. Recomputation — не храним intermediate attention weights
3. Shared memory — использование on-chip memory GPU
4. Kernel fusion — объединение операций в один kernel
Результат:
- 2-4x быстрее обычного attention
- 60-80% меньше памяти
- Особенно эффективно для длинного контекста
Решение 5: Continuous Batching (In-flight Batching)
vLLM обрабатывает запросы на уровне токенов, а не целых запросов:
Обычный batching:
Request 1: [████████████░░░░░░░░] — 12 токенов из 256
Request 2: [██████░░░░░░░░░░░░] — 6 токенов из 256
Request 3: [██████████████████░░] — 20 токенов из 256
Ждём, пока все закончат. Request 2 закончился на шаге 6,
но GPU ждёт 20 шагов.
Continuous batching:
Request 1: [████████████░░░░░░░░] — 12 токенов
Request 2: [██████] — 6 токенов, ЗАКОНЧЕН → сразу новый токен!
Request 3: [██████████████████░░] — 20 токенов
Как только Request 2 закончился, GPU сразу даёт ему новый токен
и начинает новый запрос на свободных слотах.
Бенчмарки: влияние KV Cache на производительность
RTX 4090 (24 GB VRAM), Qwen 2.5 7B Q4
| Context Length | Throughput (tok/s) | Latency (ms/token) | KV Cache Size |
|---|---|---|---|
| 1K | 65 | 15.4 | 128 KB |
| 4K | 58 | 17.2 | 512 KB |
| 8K | 48 | 20.8 | 1 MB |
| 16K | 35 | 28.6 | 2 MB |
| 32K | 22 | 45.5 | 4 MB |
| 64K | 12 | 83.3 | 8 MB |
vLLM vs llama.cpp vs Transformers
| Engine | Throughput (tok/s) | GPU Util | Max Parallel Requests |
|---|---|---|---|
| Hugging Face Transformers | 28 | 35% | 3 |
| llama.cpp | 42 | 55% | 5 |
| vLLM (PagedAttention) | 78 | 78% | 12 |
| vLLM + Prefix Caching | 95 | 82% | 15 |
Влияние prefix caching
10 запросов с одинаковым system prompt (500 токенов):
Без prefix caching:
Avg latency: 4.2s на запрос
Total time: 42s (последовательные)
С prefix caching:
Request 1: 4.2s (compute prefix)
Request 2: 2.8s (reuse prefix)
Request 3: 2.7s (reuse prefix)
...
Avg latency: 2.85s на запрос
Speedup: 1.47x
Практические рекомендации
Как подобрать context length
# Формула для расчёта нужной VRAM:
# VRAM = model_size + kv_cache_size + overhead
# Qwen 2.5 7B Q4 на RTX 4090 (24 GB):
# Model: 4 GB
# Overhead (CUDA, buffers): 2 GB
# Available for KV Cache: 18 GB
# KV Cache на 32K: 4 MB
# Max requests: 18000 / 4 = 4500 параллельных запроса с context=32K
# (но это теоретический максимум)
# Практическая рекомендация:
# 4K context — для коротких ответов (кодинг, классификация)
# 8K context — для чата с историей
# 16K context — для анализа документов
# 32K+ context — для длинных документов (редко нужно)
Настройки для оптимизации
# vLLM оптимизации
llm = vllm.LLM(
model="Qwen/Qwen2.5-7B-Instruct",
max_model_len=8192, # ограничиваем context length
gpu_memory_utilization=0.85, # используем 85% GPU памяти
enable_prefix_caching=True, # кэшируем prefix
swap_space=4, # disk swap для KV Cache
max_num_batched_tokens=8192,
)
# llama.cpp оптимизации
./main \
-m models/qwen2.5-7b.Q4_K_M.gguf \
-c 8192 \ # context length
-ngl 80 \ # layers on GPU
-t 8 \ # threads
-pb 1 \ # prefix batching
-ngl 80 # всё на GPU
Когда длинный контекст НЕ нужен
Короткий контекст (1K-4K):
✅ Код-генерация, рефакторинг
✅ Классификация, extraction
✅ Короткие ответы на вопросы
✅ API с короткими промптами
Средний контекст (4K-16K):
✅ Чат-бот с историей
✅ Анализ средних документов
✅ Перевод текстов до 10 страниц
Длинный контекст (16K+):
✅ Анализ больших документов
✅ Полные кодовые базы
✅ Юридические/медицинские тексты
⚠️ Значительно медленнее
⚠️ Больше память
⚠️ Не всегда лучше качество
KV Cache vs другие оптимизации
Комбинация с квантованием
Qwen 7B на RTX 4090:
FP16, context 8K:
Model: 14 GB
KV Cache: 1 MB
Throughput: 28 tok/s
Q4, context 8K:
Model: 4 GB
KV Cache: 1 MB
Throughput: 58 tok/s (+107%)
Q4 + vLLM PagedAttention, context 8K:
Model: 4 GB
KV Cache: 1 MB
Throughput: 78 tok/s (+179%)
Q4 + vLLM + Prefix Caching, context 8K:
Model: 4 GB
KV Cache: 1 MB
Throughput: 95 tok/s (+239%)
Сравнение методов оптимизации
| Метод | Сложность | Ускорение | Сокращение памяти | Работает с |
|---|---|---|---|---|
| PagedAttention | Низкая | 2-4x | 40-60% | vLLM |
| Prefix Caching | Низкая | 1.5-3x | 30-80% | vLLM |
| Flash Attention | Средняя | 2-4x | 60-80% | Все движки |
| KV Quantization | Средняя | 1x | 50% | llama.cpp |
| Context Limiting | Низкая | 1-2x | 50-90% | Все движки |
| KV Eviction | Средняя | 1-1.5x | 30-70% | Все движки |
Типичные проблемы и решения
Проблема 1: Out of Memory при длинном контексте
Симптом: CUDA out of memory при context > 16K.
Решение:
- Уменьшите
max_model_len/-c - Используйте квантованную модель (Q4 вместо FP16)
- Включите vLLM для лучшего управления памятью
- Уменьшите
gpu_memory_utilization
Проблема 2: Медленный prefill (системный промпт)
Симптом: первая задержка 5-10 секунд, потом быстро.
Решение:
- Включите
enable_prefix_caching=Trueв vLLM - Сократите системный промпт
- Используйте
--prompt-cacheв llama.cpp - Разделите prompt и user query
Проблема 3: Degradation качества на длинном контексте
Симптом: модель отвечает хуже при context > 32K.
Решение:
- Используйте модель с native long context (128K+)
- Проверьте, что RoPE scaling настроен правильно
- Попробуйте NTK scaling или YaRN
- Сократите контекст до 8K-16K
Проблема 4: Нестабильный throughput при параллельных запросах
Симптом: то 80 tok/s, то 20 tok/s.
Решение:
- Используйте vLLM с continuous batching
- Ограничьте max_num_seq (макс параллельных запросов)
- Унифицируйте max_model_len
- Проверьте GPU utilization
Чек-лист: оптимизация KV Cache
- Измерьте текущий GPU memory usage
- Рассчитайте KV Cache size для вашего context length
- Переключитесь на vLLM если ещё не используете
- Включите
enable_prefix_caching=True - Установите оптимальный
max_model_len(8K-16K для чата) - Используйте Flash Attention (если поддерживается)
- Квантуйте модель (Q4 или Q5)
- Мониторьте GPU utilization (цель: >70%)
- При OOM — уменьшите context length или batch size
- Сократите системный промпт до необходимого минимума
Итоги
KV Cache — фундаментальная часть LLM, которая определяет:
- Сколько памяти нужно — зависит от context length и размера модели
- Насколько быстро работает — attention на длинном контексте замедляется
- Сколько параллельных запросов можно запустить — ограничено памятью GPU
Лучшие практики:
- Используйте vLLM с PagedAttention для лучшего управления памятью
- Включите prefix caching для повторяющихся промптов
- Ограничьте context length до необходимого минимума
- Комбинируйте с квантованием для максимальной эффективности
- Используйте Flash Attention для длинных контекстов
С чего начать:
# vLLM с оптимальными настройками
vllm serve Qwen/Qwen2.5-7B-Instruct \
--max-model-len 8192 \
--gpu-memory-utilization 0.85 \
--enable-prefix-caching
# llama.cpp с ограниченным контекстом
./main -m qwen2.5-7b.Q4_K_M.gguf \
-c 8192 \
-ngl 80 \
-t 8
Понимание KV Cache — ключ к эффективному развёртыванию локальных LLM. Без оптимизации памяти даже мощная GPU может работать на 30% эффективности. С оптимизацией — тот же GPU покажет 3-4x рост throughput.