Недавно лимиты на использование 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 делает:
- Tiled IO-aware вычисление (FlashAttention)
- 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. На каждом шаге:
- Запланировать все decode-запросы (по 1 токену — дёшево)
- Оставшийся бюджет (max_num_batched_tokens) — заполнить prefill-чанками
- Если чанк не влезает — разбить дальше
Пример: промпт 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 на блок.
Процесс при поступлении запроса:
- Вычислить хэш для каждого блока по token ID
- Искать каждый хэш в таблице, начиная с блока 0
- Первый промах → отсюда начинается prefill
- Все блоки до промаха → 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-данные между нодами — разберём во второй части.