Не так давно лимиты на использование Claude Code резко уменьшились, и люди стали лучше считать свои токены. Я не стал исключением, поэтому первым делом собрал информацию по использованию токенов в своих сессиях и посмотрел, что и сколько бы стоило, если бы отправлял это через API.

Claude Code Session Usage
Claude Code Session Usage

Как человек, который занимается темой LLM уже несколько лет, я поймал себя на мысли, что не до конца осознаю, почему Cache Read и Cache Write столько стоят и что вообще стоит за этими цифрами. Так появилась идея разобраться со всей этой темой основательно: повторить основы и выйти на "PRO" уровень, где мы понимаем логику и цену кеша на масштабе. Своим исследованием я решил поделиться через серию статей:

  • Текущая статья покрывает базовое понимание KV-Cache: как он создаётся и используется в рамках одного GPU.

  • Вторая часть серии разберёт распределённый KV-Cache: проблемы, логику и фреймворки масштабирования за рамки одного GPU.


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

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

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

Шаг 1: K, V для токенов 1..4097    → 4097 проекций
Шаг 2: K, V для токенов 1..4098    → 4098 проекций
...
Шаг 100: K, V для токенов 1..4196  → 4196 проекций

Суммарно: 100 × 4096 + (100 × 101)/2 ≈ 414,650 проекций

На 70B такой объём матричных умножений стоит неприлично дорого по времени и вычислительным ресурсам, а почти всё это повторные вычисления одних и тех же K, V.

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

Инференс 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 тензоры с каждого шага, на каждом новом шаге вычислять проекцию только для одного нового токена и добавлять результат в кэш.

Шаг 1: K, V для токена 4097, append → O(1) новых проекций
Шаг 2: K, V для токена 4098, append → O(1) новых проекций
...
Шаг 100: K, V для токена 4196, append → O(1) новых проекций

Суммарно: 100 проекций вместо 414,650

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

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

  • TTFT (Time To First Token) — определяется длительностью prefill. Длинный промпт = долгий TTFT. Это latency, которую пользователь видит как «задержку перед ответом».

  • ITL (Inter-Token Latency) — определяется скоростью decode. Один forward pass на токен. Это определяет, насколько «плавным» кажется потоковый вывод.

  • Throughput — сколько токенов в секунду система генерирует суммарно по всем запросам. Здесь ключевой вопрос — сколько запросов можно обрабатывать одновременно, а это зависит от того, сколько KV-Cache влезает в память.

Prefill выигрывает от больших батчей токенов (GPU загружен вычислениями). Decode выигрывает от больших батчей запросов (GPU загружен чтением KV-Cache, и больше запросов = лучшая утилизация bandwidth). Обе фазы делят ресурсы 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. Формула:

KV-Cache = 2 × num_layers × num_kv_heads × d_head × seq_len × dtype_bytes

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

Параметр

Значение

num_layers

80

num_kv_heads

8 (GQA: 8 KV heads на 64 query heads)

d_head

128

dtype

BF16 (2 байта)

На один токен:

2 × 80 × 8 × 128 × 2 = 327,680 байт ≈ 320 КБ

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

Длина контекста

KV-Cache на запрос

Это как…

4,096

1.25 ГБ

Half Life

32,768

10 ГБ

Fallout: New Vegas

131,072 (128K)

40 ГБ

Dota 2

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

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

Модель

Тип

Слои

KV-heads

d_head

KV на токен (BF16)

128K контекст

Llama 3.1 70B

Dense

80

8

128

320 КБ

40 ГБ

Qwen3-8B

Dense

36

8

128

144 КБ

18 ГБ

Qwen3-235B-A22B

MoE

94

4

128

188 КБ

24 ГБ

MiniMax M2.5

MoE

62

8

128

248 КБ

32 ГБ

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

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

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

Без GQA: 2 × 80 × 64 × 128 × 2 = 2,621,440 байт ≈ 2.5 МБ на токен
128K контекст → 320 ГБ KV-Cache → не влезает ни на один GPU

Для средне-жирных моделей класса 70B GQA — по сути стандартный способ удержать KV-cache в разумных пределах. При полном Multi Head Attention и тех же 64 query heads объём KV вырос бы примерно в 8 раз, и на типичном 1 GPU сетапе длинный контекст стал бы крайне тяжёлым по памяти, окна уже в тысячи токенов давили бы на 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.

Подход

Контекст 4K

Контекст 32K

Контекст 128K

Наивный (alloc на max_seq_len = 4K)

~33 запроса (при средней утилизации 50%) → 16 эффективных

4 запроса → 2 эффективных

1 запрос

Идеальный (alloc точно по факту)

33 запроса

4 запроса

1 запрос

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

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

3. Вспомним FlashAttention:

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

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

Стандартный attention:

\operatorname{Attention}(Q, K, V) = \operatorname{softmax}\left(\frac{Q K^\top}{\sqrt{d_k}}\right) V

Промежуточная матрица Q · K^T на этапе prefill имеет размер [seq_len × seq_len]. При 128K контексте:

131,072 × 131,072 × 2 байта (BF16) = 32 ГБ — на одну head, на одном слое

У Llama-70B 64 query heads и 80 слоёв:

32 ГБ × 64 × 80 = 163,840 ГБ ≈ 160 ТБ

160 терабайт промежуточных данных, если материализовать все матрицы одновременно. Ваш GPU имеет 80 ГБ. На практике наивная реализация обрабатывает по одному слою, но даже так: при 128K контексте одна attention-матрица для одной head на одном слое — 32 ГБ. Это уже не влезает. При 4K токенах: 32 МБ на head — технически влезает, но стандартная реализация делает 4 захода к HBM для каждой такой матрицы, что неэффективно.

Стандартная реализация записывает эту матрицу в HBM, читает обратно для softmax, записывает результат softmax, снова читает для умножения на V. Четыре похода в медленную память для матрицы, которая не нужна после вычисления.

Tiling и online softmax

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

Память

Объём (A100)

Bandwidth

Роль

HBM

80 ГБ

~2 ТБ/с

Основная память, хранит всё

SRAM

~20 МБ

~19 ТБ/с

On-chip, в ~10× быстрее

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

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

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

# Pseudocode: FlashAttention forward pass
# Q, K, V в HBM, размер блока B_r (по Q) и B_c (по K,V)

O = zeros(N, d)           # выходной тензор в HBM
m = -inf(N)               # running max по строкам
l = zeros(N)              # running sum по строкам

for j in range(0, N, B_c):                    # внешний цикл по K,V блокам
    K_j = load(K[j : j+B_c])                  # загрузить K-блок в SRAM
    V_j = load(V[j : j+B_c])                  # загрузить V-блок в SRAM
  
    for i in range(0, N, B_r):                 # внутренний цикл по Q блокам
        Q_i = load(Q[i : i+B_r])              # загрузить Q-блок в SRAM
        O_i = load(O[i : i+B_r])              # загрузить текущий результат
        m_i = load(m[i : i+B_r])              # загрузить текущий max
        l_i = load(l[i : i+B_r])              # загрузить текущую сумму
  
        # --- всё дальше в SRAM ---
        S_ij = Q_i @ K_j.T / sqrt(d)          # локальные attention scores
        m_new = max(m_i, rowmax(S_ij))         # обновить running max
        P_ij = exp(S_ij - m_new)               # локальные attention weights
        l_new = l_i * exp(m_i - m_new) + rowsum(P_ij)  # обновить running sum
  
        # пересчитать ранее накопленный результат + добавить новый вклад
        O_i = O_i * (l_i * exp(m_i - m_new) / l_new) + (P_ij / l_new) @ V_j
  
        store(O[i : i+B_r], O_i)              # записать результат в HBM
        store(m[i : i+B_r], m_new)
        store(l[i : i+B_r], l_new)

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

FlashAttention banner showing the tiling approach and memory hierarchy
FlashAttention banner showing the tiling approach and memory hierarchy

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

Контринтуитивный факт: FlashAttention выполняет больше операций с плавающей точкой, чем стандартный attention. Пересчёт масштабов на каждом тайле — дополнительные умножения и экспоненты. Но алгоритм всё равно быстрее, потому что bottleneck — bandwidth, не compute.

Грубая арифметика для A100 при N=4096, d=128:

  • Стандартный attention: ~33M обращений к HBM (доминирует N²)

  • FlashAttention: ~4.3M обращений к HBM

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

  • Стандартный: O(N·d + N²) обращений к HBM

  • FlashAttention: O(N²·d²·M⁻¹) обращений к HBM, где M — размер SRAM

Оба подхода масштабируются как O(N²), но с радикально разными константами. У FlashAttention множитель d²/M — для A100 с d=128 и M≈20 МБ это ≈0.0008. На практике при N = 128K это означает разницу в тысячи раз по числу обращений к HBM. Tri Dao доказал matching lower bound: никакой алгоритм точного attention не может быть асимптотически лучше по IO.

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

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

4. Вспомним PagedAttention:

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

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

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

Внутренняя фрагментация. Буфер выделяется на max_seq_len токенов. Если запрос использует 200 из 4096 — 95% потеряно. Средние потери на реальных нагрузках: 60–80% (данные vLLM).

Внешняя фрагментация. По мере поступления и завершения запросов свободная память дробится на мелкие несмежные куски. Суммарно свободных 10 ГБ — но ни одного непрерывного блока на 1.25 ГБ для нового запроса.

Проблема, решённая для CPU RAM десятилетия назад. Решение — виртуальная память с пейджингом.

Блоки и block table

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

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

PagedAttention: logical-to-physical block mapping via block tables
PagedAttention: logical-to-physical block mapping via block tables

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

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

Prefill:

  • 1024 / 16 = 64 блока

  • Block manager выделяет 64 физических блока из пула. Они разбросаны по GPU-памяти — это нормально.

  • Block table: логический блок 0 → физический 417, блок 1 → физический 23, блок 2 → физический 891, …

  • Все 64 блока заполняются KV-данными из prefill forward pass.

Decode, токен 1025:

  • Блок 63 (токены 1009–1024) — полон, 16/16 слотов.

  • Выделяется блок 64. Токен 1025 записывается в слот 1/16.

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

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

Итого:

Выделено:    65 блоков × 16 слотов = 1,040 слотов
Использовано: 1,034 токена
Потери:      6 слотов (0.6%)

Наивный подход с max_seq_len = 2048:

Выделено:    2,048 слотов
Использовано: 1,034 токена
Потери:      1,014 слотов (49.5%)

С 0.6% до 49.5% — разница в ~80× по потерям памяти.

В терминах throughput: при наивном подходе на 42 ГБ свободной памяти с max_seq_len = 2048 мы можем разместить 42 ГБ / (2048 × 320 КБ) ≈ 67 запросов. Но если средняя реальная длина — 500 токенов, эффективно используется лишь 500/2048 = 24% памяти, и мы могли бы обслужить 67 / 0.24 ≈ 280 запросов. PagedAttention приближает нас к этим 280.

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

Размер блока

Плюсы

Минусы

1 токен

Нулевая фрагментация

Огромная block table, больше overhead

16 токенов

Хороший баланс; макс. 15 потерянных токенов (~4.8 КБ)

256 токенов

Мало блоков, меньше overhead

До 255 потерянных токенов (~80 КБ) на запрос

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

Copy-on-Write для parallel sampling

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

Copy-on-Write mechanism for shared KV blocks
Copy-on-Write mechanism for shared KV blocks

Это экономит до 55% памяти при beam search (данные из оригинальной статьи PagedAttention). При 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 — online softmax, минимум походов к HBM)

  2. Scattered page reads (PagedAttention — чтение KV из физических блоков)

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

Помимо FlashAttention, vLLM поддерживает FlashInfer — альтернативный backend со своими оптимизированными ядрами и paged KV support. Для AMD и Intel GPU есть Triton-based backend — кросс-платформенная реализация на Triton.

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

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

output = flash_attn_with_kvcache(
    q=query,                    # [batch, 1, num_heads, d_head]
    k_cache=kv_cache_k,         # [num_physical_blocks, block_size, num_kv_heads, d_head]
    v_cache=kv_cache_v,         # [num_physical_blocks, block_size, num_kv_heads, d_head]
    block_table=block_table,    # [batch, max_num_blocks] — int32 physical block IDs
    cache_seqlens=seq_lengths,  # [batch] — реальная длина каждого запроса
)

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, этот шаг занимает долго (на H100 для Llama-70B полный prefill такого масштаба часто порядка 2–5 с — зависит от настроек). Пока шаг не закончен, decode всех остальных запросов не двигается: в интерфейсе это выглядит как пауза в потоковой генерации. Чанки укладываются в общий бюджет токенов на шаг рядом с decode — очередь снова становится «живой».

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

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

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

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

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

Scheduler ведёт две очереди: decode (текущие генерации) и prefill (новые промпты). На каждом шаге:

  1. Запланировать все ожидающие decode-запросы (по 1 токену каждый — дёшево)

  2. Оставшийся бюджет (max_num_batched_tokens) — заполнить prefill-чанками

  3. Если чанк не влезает — разбить дальше

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

Step 1: [A: chunk 1, 2048 prefill] + [B: 1 decode]  → B получает токен
Step 2: [A: chunk 2, 2048 prefill] + [B: 1 decode]  → B получает токен
Step 3: [A: chunk 3, 2048 prefill] + [B: 1 decode]  → B получает токен
Step 4: [A: chunk 4, 2048 prefill] + [B: 1 decode]  → B получает токен
Step 5: [A: chunk 5, 1808 prefill] + [B: 1 decode]  → A prefill завершён
Step 6: [A: 1 decode] + [B: 1 decode]                → оба генерируют

Без чанков B заблокирован на шаги 1–5. С чанками — стабильный inter-token latency на каждом шаге. При промпте 100K шагов было бы ~49, но принцип тот же.

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

Параметр max_num_batched_tokens

Этот параметр — главная ручка настройки chunked prefill. Он контролирует, сколько токенов (суммарно prefill + decode) scheduler может запланировать за один шаг.

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

  • Большое значение (4096–8192): prefill быстрее, TTFT ниже. Но decode-запросы конкурируют за бюджет с крупными prefill-чанками → ITL может подскочить.

  • Типичный дефолт (2048): разумный баланс для большинства нагрузок.

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

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

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

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

Обычный key-value кэш (в смысле структуры данных) потребовал бы отдельный lookup на каждый блок и независимую валидацию. APC устроен иначе благодаря hash chaining.

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

2000 токенов × 320 КБ × 1000 запросов = 625 ГБ KV-данных в минуту пересчитывается заново

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

Hash chaining

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

hash(block_i) = hash(token_ids[0 .. last_token_of_block_i])

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

Почему не хэшировать токены каждого блока по отдельности? Потому что одни и те же 16 токенов на разных позициях имеют разные KV-значения. Causal attention: KV для токена зависит от всех предыдущих. Токены [“Say”, “My”, “Name”, …] на позиции 100 дадут совсем другие K и V, чем те же токены на позиции 500. Включая полный префикс в хэш, мы гарантируем: совпадение хэша = идентичные KV-данные.

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

Плоская хэш-таблица: block_hash → physical_block_id. Никаких деревьев, никаких trie. 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 помогает

Сценарий

Эффект APC

Общий длинный системный промпт

Огромная экономия — prefill только для user-части

RAG с повторяющимися документами

Хорошая экономия при cache hit на общие чанки

Parallel sampling (N ответов на 1 промпт)

Полное переиспользование KV промпта

Каждый запрос уникален, короткий контекст

Минимальная польза

Высокое давление на память

Блоки вытесняются до переиспользования

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

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

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

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

Альтернативный подход — хранить кэшированные последовательности в trie (prefix tree). Каждый узел — токен, путь от корня — последовательность. Lookup — спуск по дереву.

Проблемы 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 блоков исключает необходимость в инвалидации. Плоская хэш-таблица вместо trie даёт простоту и скорость.

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

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

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

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

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

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

Кэш пуст, оба запроса попали в один batch. Что происходит:

  • Оба запроса выполняют prefill для общего префикса. KV-вычисление дублируется — это неизбежно, потому что forward pass выполняется для обоих.

  • Но физическая память не дублируется: block manager через reference counting может направить обе block table на одни и те же физические блоки.

  • После этого шага блоки в кэше. Все следующие запросы — cache hit.

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

Запрос 1 уже обработан (или частично обработан через chunked prefill). Запрос 2 приходит позже:

  • Lookup по хэшам находит кэшированные блоки запроса 1

  • Полный 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 токенов).

Каждая реплика кэширует системный промпт независимо:
8 копий × 625 МБ = 5 ГБ суммарной GPU-памяти на один системный промпт

Cache hit rate на реплику: первый запрос на каждую реплику — промах.
При равномерном распределении: период прогрева кэша в 8× дольше.
Эффективная cache hit rate ≈ 1/N от одного инстанса (на начальном этапе).

При 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-нативная архитектура для prefix-aware routing LLM-инференса) и LMCache (передача KV-Cache между инстансами через сеть или общее хранилище).

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

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

Заключение

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

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

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