KV-Cache в LLM: разбираем инференс через 9 ключевых вопросов

KV-Cache в LLM: разбираем инференс через 9 ключевых вопросов

Недавно лимиты на использование Claude Code резко сократились, и пользователи стали внимательнее считать свои токены. Я тоже задумался: почему операции Cache Read и Cache Write стоят так дорого? Что стоит за этими цифрами? Чтобы разобраться, я решил систематизировать знания о KV-Cache — от основ до продвинутых решений. Результат — первая часть цикла статей:

  • Эта статья объясняет, как работает KV-Cache на одном GPU: от prefill и decode до FlashAttention и PagedAttention.
  • Вторая часть будет посвящена распределённому кэшированию: масштабированию за пределы одного устройства, проблемам и фреймворкам.

1. Prefill и Decode — два разных мира

Вопрос: Каждый шаг генерации LLM — это одно и то же?

Представьте: промпт из 4096 токенов, модель генерирует 100 токенов. Без кэширования на каждом шаге заново вычисляются K и V для всех предыдущих токенов. Для модели в 70 млрд параметров это огромные затраты времени и ресурсов — почти все вычисления повторяются.

Как устроено на самом деле

Инференс LLM делится на две фазы с разным характером нагрузки.

Prefill — обработка всего промпта за один forward pass. Все токены известны заранее, attention вычисляется параллельно. Эта фаза compute-bound: GPU загружен арифметикой. Результат — KV-представления для всех токенов промпта и логиты первого генерируемого токена.

Decode — авторегрессивная фаза. Каждый новый токен требует отдельного forward pass. Один Q-вектор вычисляет attention со всеми предыдущими K. Эта фаза memory-bound: GPU в основном ждёт, пока KV-Cache прочитается из HBM (память GPU). Вычислений мало — один вектор против матрицы.

KV-Cache — простое решение: сохранять K и V тензоры с каждого шага. На новых шагах вычисляется проекция только для нового токена, а результат добавляется в кэш.

Влияние на production-метрики

Разница между фазами определяет три ключевые метрики:

  • TTFT (Time To First Token) — зависит от длительности prefill. Длинный промпт = долгой TTFT. Это задержка, которую пользователь видит перед началом ответа.
  • ITL (Inter-Token Latency) — определяется скоростью decode. Один forward pass на токен. Отвечает за плавность потокового вывода.
  • Throughput — сколько токенов в секунду генерирует система по всем запросам. Зависит от того, сколько KV-Cache помещается в память.

Prefill выигрывает от больших батчей токенов (GPU занят вычислениями). Decode — от большого числа одновременных запросов (GPU занят чтением, и больше запросов = лучше использование пропускной способности памяти). Эти фазы конкурируют за ресурсы, что приводит к chunked prefill (см. раздел 6).

Prefill — compute-bound и параллельный. Decode — memory-bound и последовательный. KV-Cache превращает O(n·m + m²) повторных вычислений в O(m), но создаёт проблему хранения.

2. KV-Cache: цена памяти

Вопрос: Сколько памяти на самом деле занимает KV-Cache?

KV-Cache хранит K и V проекции для каждого токена, слоя и KV-head. Формула:

Рассчитаем для Llama 3.1 70B:

  • num_layers = 80
  • num_kv_heads = 8 (GQA: 8 KV heads на 64 query heads)
  • head_dim = 128
  • dtype = BF16 (2 байта)

На один токен: 80 × 8 × 128 × 2 × 2 = 320 КБ.

Масштабируем:

  • Длина контекста: 4K → KV-Cache на запрос: ~1.25 ГБ
  • 32K → ~10 ГБ
  • 128K → ~40 ГБ

При FP8-формате — вдвое меньше: ~160 КБ на токен и ~20 ГБ на 128K. В полностью FP8-инференсе KV автоматически будет FP8. Если веса в INT4, K/V обычно в FP16. Перевести кеш на FP8 можно через --kv-cache-dtype fp8 в vLLM — сжатие с небольшой потерей точности. FP8 KV часто даёт 2× пропускную способность без смены GPU.

Сравнение с другими моделями

KV-Cache на токен (BF16):

  • Llama 3.1 70B: 320 КБ
  • Qwen3-235B-A22B: 200 КБ
  • MiniMax M2.5: 248 КБ

Qwen3-235B-A22B (235 млрд параметров) имеет меньше KV-Cache на токен, чем Llama 70B, благодаря агрессивному GQA (всего 4 KV-head, соотношение 16:1). MiniMax M2.5 при 230 млрд параметров — 248 КБ/токен, что тоже меньше Llama 70B из-за меньшего числа слоёв (62 против 80). Архитектурные решения напрямую влияют на стоимость инференса.

GQA: 8× сокращение

Llama 70B использует Grouped-Query Attention (GQA) — 8 KV heads вместо 64 query heads. Каждая KV head обслуживает 8 query heads. Это сокращает KV-Cache в 8 раз.

Для моделей класса 70B GQA — стандарт. Без него объём KV вырос бы в 8 раз, и длинный контекст стал бы крайне тяжёлым по памяти. Окна в тысячи токенов давили бы на KV, а 128K стало бы практически недостижимым без шардинга.

Проблема наивного выделения памяти

Наивный подход — заранее выделить непрерывный буфер max_seq_len × 320 КБ на каждый запрос. Если max_seq_len = 4096, а генерация остановилась на 200 токенах — 95% памяти потрачено впустую. vLLM показала, что такой подход тратит 60–80% памяти KV-Cache впустую.

Разница между 36 и 8 одновременными пользователями. Между $4 и $18 за миллион токенов.

Пример: H100, 80 ГБ. Веса Llama-70B в INT4: ~35 ГБ. Активации, буферы, overhead — 3 ГБ. Остаётся 42 ГБ под KV-Cache.

При наивном выделении на коротких запросах (200–500 токенов) теряется более половины памяти. Решение — PagedAttention (раздел 4).

KV-Cache может быть больше весов модели. При длинных контекстах — главный потребитель GPU-памяти. Наивное управление теряет большую часть throughput.

3. Вспомним FlashAttention

Вопрос: FlashAttention — это приближённые вычисления? И он решает проблему KV-Cache?

FlashAttention вычисляет точный attention и решает проблему вычисления. Хранение KV-Cache — отдельная задача.

Стандартный attention создаёт промежуточную матрицу Q · K^T размером [seq_len × seq_len]. При 128K контексте:

У Llama-70B: 64 query heads, 80 слоёв. При 128K токенах одна attention-матрица — 32 ГБ. Это не влезает в GPU. Даже при 4K — 32 МБ на head, что неэффективно из-за четырёх походов в HBM.

Tiling и online softmax

Ключевое наблюдение Tri Dao (2022): проблема attention — не вычисления, а перемещение данных. У GPU два уровня памяти:

  • HBM — основная, медленная
  • SRAM — on-chip, в ~10× быстрее

A100: 312 TFLOPS BF16, 2 ТБ/с bandwidth. Соотношение: ~156 операций на байт. Если алгоритм выполняет меньше — он упирается в bandwidth. Стандартный attention именно такой.

Алгоритм tiling с online softmax

FlashAttention не материализует полную N × N матрицу. Вместо этого Q, K, V обрабатываются блоками (tiles).

Математически тонкая часть — online softmax. Softmax требует глобального max и суммы экспонент, но K-блоки обрабатываются по одному. Решение: поддерживать running max и running sum, пересчитывая их при каждом новом блоке через exp(m_old - m_new). После всех блоков результат идентичен полному attention. Без приближений.

Почему больше FLOP’ов = быстрее

FlashAttention выполняет больше операций, чем стандартный attention. Но он быстрее, потому что bottleneck — bandwidth, а не compute.

Пример для A100, N=4096, d=128:

  • Стандартный attention: ~33M обращений к HBM
  • FlashAttention: ~4.3M обращений к HBM

IO-сложность:

  • Стандартный: O(N·d + N²)
  • FlashAttention: O(N²·d²·M⁻¹), где M — размер SRAM

При N = 128K разница в тысячи раз по числу обращений к HBM. Tri Dao доказал: никакой алгоритм точного attention не может быть асимптотически лучше по IO.

Чего FlashAttention НЕ делает

FlashAttention оптимизирует вычисление attention — как быстро перемножить Q с кэшированными K,V. Управление памятью, фрагментация, переиспользование блоков — за его пределами. KV-Cache остаётся в HBM. FlashAttention ускоряет kernel, который его читает. Управлением памяти занимается PagedAttention.

4. Вспомним PagedAttention

Вопрос: PagedAttention — это альтернатива FlashAttention?

Нет. PagedAttention — это техника управления памятью: где в GPU-памяти лежат K и V. FlashAttention и PagedAttention — ортогональные решения. Современные системы используют оба.

Без PagedAttention каждый запрос получает непрерывную область памяти под KV-Cache. Два типа потерь:

  • Внутренняя фрагментация: буфер выделяется на max_seq_len. Если запрос использует 200 из 4096 — 95% потеряно. Средние потери: 60–80%.
  • Внешняя фрагментация: свободная память дробится на мелкие несмежные куски. Суммарно 10 ГБ свободно — но нет блока на 1.25 ГБ для нового запроса.

Решение — виртуальная память с пейджингом.

Блоки и block table

KV-Cache делится на блоки фиксированного размера (по умолчанию — 16 токенов). Блоки не обязаны быть смежными в GPU-памяти.

Block table — аналог page table в ОС — маппит логические блоки на физические адреса в GPU-памяти.

Пошаговый пример

Запрос: промпт на 1024 токена, генерация 10 токенов. Размер блока: 16.

  • 1024 / 16 = 64 блока
  • Block manager выделяет 64 физических блока из пула (они могут быть разбросаны)
  • Block table: логический блок 0 → физический 417, блок 1 → физический 23 и т.д.
  • Все 64 блока заполняются KV-данными из prefill

Decode, токен 1025:

  • Блок 63 (токены 1009–1024) — полон
  • Выделяется блок 64. Токен 1025 записывается в слот 1/16

Decode, токены 1026–1034:

  • Токены заполняют слоты 2–10 блока 64. После: 10/16 слотов занято

При наивном подходе с max_seq_len = 2048 потери памяти — 49.5%. С PagedAttention — 0.6%. Разница в ~80×.

Throughput: при 42 ГБ свободной памяти и max_seq_len = 2048 наивный подход позволяет разместить ~67 запросов. При средней длине 500 токенов эффективно используется 24% памяти — можно было бы обслужить 280 запросов. PagedAttention приближает к этому.

Выбор размера блока

Размер блока влияет на баланс между фрагментацией и overhead:

  • 1 токен: нулевая фрагментация, но огромная block table
  • 16 токенов: хороший баланс; максимум 15 потерянных токенов (~4.8 КБ)
  • 256 токенов: мало блоков, меньше overhead, но до 255 потерянных токенов (~80 КБ)

16 — дефолт в vLLM. Совпадает с гранулярностью GPU: размер тайла attention kernel и требования к выравниванию. Больший размер может быть оправдан при очень длинных последовательностях, где block table становится заметной.

Copy-on-Write для parallel sampling

При генерации нескольких вариантов ответа (beam search, parallel sampling) KV-блоки общего префикса не копируются. Обе последовательности ссылаются на одни и те же физические блоки через reference counting. Если одна должна изменить разделяемый блок — он сначала копируется (copy-on-write). Оригинал остаётся нетронутым.

Это экономит до 55% памяти при beam search. При parallel sampling — 6–10%.

PagedAttention — виртуальная память для GPU, решающая фрагментацию и эффективное выделение памяти.

5. Как vLLM объединяет FlashAttention и PagedAttention

Вопрос: У vLLM есть отдельный PagedAttention kernel?

Нет. Современный vLLM делегирует attention внешним бекендам. FlashAttention теперь нативно поддерживает параметр block_table: kernel знает, что KV-данные лежат в разбросанных блоках, и собирает их самостоятельно. Один fused kernel делает:

  1. Tiled IO-aware вычисление (FlashAttention)
  2. Scattered page reads (PagedAttention)

Нет отдельного «PagedAttention kernel» — есть FlashAttention kernel, понимающий paged memory.

Помимо FlashAttention, vLLM поддерживает:

  • FlashInfer — альтернативный backend с paged KV support
  • Triton-based backend — для AMD и Intel GPU

Как это выглядит в коде

На верхнем уровне вызов при decode:

block_table — мост между PagedAttention (где данные) и FlashAttention (как вычислять). Kernel использует его, чтобы собрать K,V из разбросанных блоков в SRAM-тайлы, вычислить attention и записать результат — всё в одной fused операции.

6. Chunked Prefill: проблема не в памяти

Вопрос: Зачем нужен chunked prefill, если FlashAttention справляется с любой длиной?

FlashAttention справляется с attention любой длины — он O(N) по памяти. Chunked prefill решает три другие проблемы.

Проблема 1: не даём одному prefill «съесть» весь шаг

GPU обрабатывает prefill и decode в одном батче. Если промпт на 100K токенов прогнать одним forward, шаг займёт 2–5 секунды (на H100, Llama-70B). Пока он не закончится, decode других запросов не двигается — пауза в потоковой генерации. Чанки укладываются в общий бюджет токенов, и очередь остаётся «живой».

Проблема 2: KV резервировать порциями, а не разом

Для Llama-70B — ~320 КБ на токен KV. 100K токенов ≈ 31 ГБ под кэш. Запрашивать всё сразу рискованно. Чанк 2048 токенов — ~640 МБ за раз; между чанками другие сессии успевают завершиться и освободить блоки.

Проблема 3: ниже пик по активациям вне attention

В остальных частях слоя (MLP, нормализации) промежуточные активации масштабируются с числом токенов. Один проход на 100K — высокий пик; те же токены чанками по 2048 — в ~50× меньший пик.

Как это работает

Scheduler ведёт две очереди: decode и prefill. На каждом шаге:

  1. Запланировать все decode-запросы (по 1 токену — дёшево)
  2. Оставшийся бюджет (max_num_batched_tokens) — заполнить prefill-чанками
  3. Если чанк не влезает — разбить дальше

Пример: промпт A на 10K токенов, max_num_batched_tokens = 2048.

Без чанков B заблокирован на шаги 1–5. С чанками — стабильный ITL на каждом шаге.

Это часть continuous batching: в отличие от static batching, запросы динамически добавляются и удаляются из батча.

Параметр max_num_batched_tokens

Главный регулятор chunked prefill:

  • Маленькое значение (512–1024): decode-запросы почти не ждут, ITL стабилен. Prefill длинных промптов растягивается → высокий TTFT.
  • Большое значение (4096–8192): prefill быстрее, TTFT ниже. Но decode-запросы конкурируют с крупными чанками → ITL может подскочить.
  • Типичный дефолт (2048): разумный баланс.

В production значение подбирается под SLO. Если SLO на ITL — 50ms, а на TTFT — 5s, можно позволить крупные чанки. Если ITL — 20ms (real-time) — чанки должны быть мельче.

7. Тот самый Cache Read

Вопрос: Чем Automatic Prefix Caching отличается от обычного кэша?

Обычно KV — рабочая память текущих запросов. После завершения блоки возвращаются в пул. Новый запрос с тем же префиксом проходит prefill с нуля.

Automatic Prefix Caching (APC) добавляет межзапросное хранение: вычисленные KV-блоки остаются в глобальной хэш-таблице и переиспользуются, пока не вытеснены по LRU (среди блоков без активных ссылок).

Пример: системный промпт на 2000 токенов. 1000 запросов в минуту. Без кэширования:

  • ~625 МБ GPU-памяти на KV-Cache
  • ~200 мс на prefill 2K токенов на Llama-70B (H100, INT4)
  • 200 секунд compute в минуту = 3.3 минуты вычислений на минуту реального времени

Hash chaining

Хэш каждого KV-блока включает все предшествующие токены, а не только токены этого блока.

Критическое свойство: если хэш блока 5 совпал у двух запросов, блоки 0–4 гарантированно совпадают. Одно совпадение валидирует весь префикс.

Почему не хэшировать блоки по отдельности? Потому что одни и те же 16 токенов на разных позициях имеют разные KV-значения (causal attention). Включая полный префикс в хэш, мы гарантируем: совпадение хэша = идентичные KV-данные.

Глобальная хэш-таблица

Плоская хэш-таблица: block_hash → physical_block_id. Никаких деревьев. O(1) lookup на блок.

Процесс при поступлении запроса:

  1. Вычислить хэш для каждого блока по token ID
  2. Искать каждый хэш в таблице, начиная с блока 0
  3. Первый промах → отсюда начинается prefill
  4. Все блоки до промаха → cache hit, физические блоки переиспользуются (ref_count++)

Конкретный пример

Системный промпт: 2000 токенов. Размер блока: 16. 125 блоков.

Запрос 1 (кэш пуст):

  • 125 блоков системного промпта + 13 блоков пользовательского сообщения (200 токенов)
  • Полный prefill: все 138 блоков вычислены с нуля
  • После завершения: блоки остаются в хэш-таблице с ref_count = 0

Запрос 2 (тот же системный промпт, другое сообщение):

  • Хэши первых 125 блоков → все найдены
  • Cache hit: 125 блоков переиспользованы, ref_count 0 → 1
  • Prefill только для 200 новых токенов (13 блоков)
  • Экономия: 2000 токенов prefill-вычислений + ~625 МБ KV-данных

Вытеснение

Блоки с ref_count == 0 остаются в памяти, пока есть место. При нехватке — вытесняется LRU-блок с ref_count == 0.

Ключевой инвариант: кэшированные блоки immutable. KV-данные блока никогда не модифицируются. Новые токены при decode идут в свежие блоки. Хэш полностью определяет содержимое — инвалидация не нужна.

Когда APC помогает

  • Общий длинный системный промпт: огромная экономия — prefill только для user-части
  • RAG с повторяющимися документами: хорошая экономия при cache hit на общие чанки
  • Parallel sampling: полное переиспользование KV промпта
  • Каждый запрос уникален, короткий контекст: минимальная польза
  • Высокое давление на память: блоки вытесняются до переиспользования

APC и chunked prefill: взаимодействие

APC работает на уровне полных блоков (16 токенов). При chunked prefill блоки заполняются и хэшируются по мере обработки чанков. Частично заполненный блок не хэшируется — он ещё mutable. Только когда блок полон, он становится immutable и попадает в хэш-таблицу.

APC-выигрыш реализуется чанк за чанком: после первого чанка (2048 токенов = 128 блоков) эти 128 блоков уже в кэше. Если второй запрос с тем же префиксом появится до завершения prefill первого — он получит частичный cache hit.

Проектное решение: хэш-таблица vs trie

Альтернатива — хранить кэши в trie (prefix tree). Но у trie проблемы:

  • Pointer chasing: 2000 токенов = 2000 случайных обращений к HBM
  • Сложность вытеснения: нельзя просто удалить лист — нужно проверить, не используется ли путь
  • Параллельный доступ: сложно обеспечить корректность

Плоская хэш-таблица с chain hashing: O(1) lookup, O(1) вытеснение, O(1) вставка. Всё за счёт одного наблюдения: хэш блока N включает всю информацию о блоках 0…N-1.

Hash chaining обеспечивает O(1) валидацию всего префикса через один lookup. Immutability исключает инвалидацию. Плоская хэш-таблица даёт простоту и скорость.

8. Параллельные запросы: почему нет race condition

Вопрос: Что происходит, когда два запроса с одним префиксом приходят одновременно?

Естественное опасение: два запроса с одним системным промптом, оба в prefill. Кто записывает блоки? Будет ли гонка?

Однопоточный scheduler

Scheduler vLLM — однопоточный. Одна нить, один шаг за раз. Никаких параллельных модификаций block table или хэш-таблицы. Целый класс race condition’ов исключён.

Сценарий 1: оба запроса в одном шаге

  • Оба выполняют prefill для общего префикса. Вычисления дублируются — неизбежно.
  • Но физическая память не дублируется: block manager через reference counting направляет обе block table на одни и те же физические блоки.
  • После шага блоки в кэше. Все следующие запросы — cache hit.

Сценарий 2: запросы в разных шагах

  • Запрос 1 уже обработан (или частично)
  • Запрос 2: lookup находит кэшированные блоки → полный cache hit
  • Нет дублирования ни вычислений, ни памяти

Сценарий 3: нужно изменить разделяемый блок

  • Speculative decoding может скорректировать токен в разделяемом блоке
  • Copy-on-write: блок копируется перед записью. Оригинал не трогается

Ключевой момент: immutability кэшированных блоков означает, что нет потребности в инвалидации. Блок записан — он корректен навсегда. Нет сценария, в котором один запрос может испортить данные другого.

Однопоточный scheduler + immutable блоки + copy-on-write = корректность без блокировок. Compute может дублироваться (оба prefill в одном batch), но память — нет.

9. Потолок одного инстанса

Вопрос: Достаточно ли добавить реплики за load balancer для масштабирования?

Throughput масштабируется, эффективность кэша — нет.

8 реплик за round-robin load balancer. Системный промпт: 2000 токенов (125 блоков по 16 токенов).

При 10 разных системных промптах × 8 реплик = 80 независимых кэширований. 50 ГБ суммарных потерь памяти на дублирование. И это без учёта RAG-документов, few-shot примеров и прочих общих префиксов.

Два направления решения:

  • Prefix-aware routing: направлять запросы с одинаковым префиксом на одну реплику. Вместо round-robin — hash по prefix → реплика. Кэш на каждой реплике становится «тёплым».
  • Distributed KV-Cache: реплики обмениваются KV-данными. Cache miss на A → получить блоки с B по сети вместо пересчёта.

Эти задачи решают llm-d (Kubernetes-нативная архитектура) и LMCache (передача KV-Cache между инстансами).

Передать 625 МБ KV-данных по 200 Gbps RDMA-сети — ~25 мс. Пересчитать prefill для 2000 токенов на H100 — ~100–200 мс. Сеть выигрывает в 4–8×, если данные уже вычислены. Это базовая экономика distributed KV-Cache.

N реплик с round-robin = N копий одного и того же кэша. Prefix-aware routing и distributed KV-Cache — необходимые условия для эффективного масштабирования. Без них добавление реплик масштабирует throughput, но убивает cache efficiency.

Заключение

Cache Write и Cache Read в Claude Code — это prefix caching, аналогичный APC из раздела 7. Системные инструкции, описание инструментов, история диалога — длинный повторяющийся префикс. При первом запросе KV-блоки вычисляются с нуля (Cache Write). При следующих — переиспользуются (Cache Read). OpenAI работает по тому же принципу: кэшированные входные токены стоят вдвое дешевле.

На масштабе провайдера встаёт вопрос: где хранить все эти KV-блоки. GPU HBM — быстро, но 80 ГБ заканчиваются за десятки запросов с длинным контекстом. CPU DRAM — больше по объёму, но медленнее. NVMe — дешевле и объёмнее, но с высокой задержкой. Выбор уровня в иерархии памяти определяет latency, стоимость хранения и цену за токен.

Как устроить эту иерархию, как маршрутизировать запросы и передавать KV-данные между нодами — разберём во второй части.

Читать оригинал