KV Cache: почему LLM тормозит при длинном контексте и как ускорить

opensourceaillmkv-cacheperformancememoryit
← Back to Blog

Введение: почему ответ 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, потому что:

  1. Attention чувствителен к точности
  2. KV Cache — меньшая часть памяти по сравнению с весами
  3. Квантование 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.

Решение:

  1. Уменьшите max_model_len / -c
  2. Используйте квантованную модель (Q4 вместо FP16)
  3. Включите vLLM для лучшего управления памятью
  4. Уменьшите gpu_memory_utilization

Проблема 2: Медленный prefill (системный промпт)

Симптом: первая задержка 5-10 секунд, потом быстро.

Решение:

  1. Включите enable_prefix_caching=True в vLLM
  2. Сократите системный промпт
  3. Используйте --prompt-cache в llama.cpp
  4. Разделите prompt и user query

Проблема 3: Degradation качества на длинном контексте

Симптом: модель отвечает хуже при context > 32K.

Решение:

  1. Используйте модель с native long context (128K+)
  2. Проверьте, что RoPE scaling настроен правильно
  3. Попробуйте NTK scaling или YaRN
  4. Сократите контекст до 8K-16K

Проблема 4: Нестабильный throughput при параллельных запросах

Симптом: то 80 tok/s, то 20 tok/s.

Решение:

  1. Используйте vLLM с continuous batching
  2. Ограничьте max_num_seq (макс параллельных запросов)
  3. Унифицируйте max_model_len
  4. Проверьте GPU utilization

Чек-лист: оптимизация KV Cache

  1. Измерьте текущий GPU memory usage
  2. Рассчитайте KV Cache size для вашего context length
  3. Переключитесь на vLLM если ещё не используете
  4. Включите enable_prefix_caching=True
  5. Установите оптимальный max_model_len (8K-16K для чата)
  6. Используйте Flash Attention (если поддерживается)
  7. Квантуйте модель (Q4 или Q5)
  8. Мониторьте GPU utilization (цель: >70%)
  9. При OOM — уменьшите context length или batch size
  10. Сократите системный промпт до необходимого минимума

Итоги

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.


Ссылки