Перейти к содержимому
5 мин чтения

Общий KV-cache между репликами без самообмана

Общий KV-cache между репликами: формулы для оценки повторного prefill, сетевого трафика, вытеснения блоков и прогрева новых реплик.

Общий KV-cache между репликами без самообмана

Общий KV-cache между репликами экономит GPU-время только в одном случае: удаленный блок приходит и становится пригоден к attention раньше, чем реплика успела бы заново просчитать тот же префикс. Все остальное, включая красивый график cache hit rate, вторично.

На практике команды часто считают только повторно сэкономленные токены. Затем включают общий слой, получают дополнительную нагрузку на fabric, вытеснение активного cache и длинный хвост TTFT. Ошибка не в самой идее shared cache. Ошибка в том, что его принимают за бесплатную дедупликацию, хотя это обмен вычисления на байты, очереди и память.

Локальный prefix cache нужно отделить от общего. Локальный cache почти не платит сетью и должен быть первым шагом. Общий слой нужен, когда один и тот же длинный префикс систематически оказывается на разных репликах, после eviction или на только что поднятом экземпляре. Документация vLLM прямо разделяет передачу KV между экземплярами через KV connector и локальное хранение; для сбоя загрузки она предлагает выбрать пересчет либо ошибку запроса. Для интерактивного сервиса пересчет обычно безопаснее, чем отказ ответа.

Сначала отделите три разных вида повторного использования

Общий пул KV-блоков, локальный prefix cache и передача prefill в decode решают разные задачи. Если смешать их в одной метрике hit rate, расчет окупаемости станет бесполезным.

Локальное повторное использование срабатывает, когда новый запрос попал на ту же реплику и его начальные блоки совпали с уже сохраненными. Это самый дешевый hit: нет сетевого чтения, нет удаленного каталога и нет копирования между машинами. Его эффективность зависит от sticky routing, размера GPU cache и правил вытеснения.

Общее повторное использование срабатывает, когда реплика нашла готовые блоки в другом узле или в общем хранилище. Оно устраняет повторный prefill, но добавляет путь чтения. Этот путь может включать GPU-память источника, RDMA или TCP, память приемника, staging-буферы и загрузку в GPU.

Prefill-decode disaggregation переносит KV из prefill-воркера в decode-воркер для одного запроса. Здесь повторного префикса между запросами может вообще не быть. Вы переносите состояние потому, что разделили вычислительные роли. LMCache, например, описывает поддержку передачи KV между prefill и decode по NVLink, RDMA или TCP, а также отдельное повторное использование префиксов между экземплярами. Это связанные, но не одинаковые механизмы.

Последствие простое. Для общего cache в числителе стоит избежанный повторный prefill. Для disaggregation в числителе стоит выигрыш от раздельного масштабирования prefill и decode. Не подставляйте одну выгоду в формулу другой архитектуры.

Размер KV надо считать по KV-головам, а не по названию модели

Объем KV для префикса определяется архитектурой attention, типом хранения и длиной совпавшей части запроса. Параметры модели в миллиардах почти ничего не говорят о размере одного KV-блока.

Для dense transformer без особых схем сжатия используйте оценку:

байт_на_токен = 2 × L × Hkv × D × q
байт_префикса = байт_на_токен × Tсовпавших

Где L - число слоев, Hkv - число KV-голов, D - размер одной головы, q - байт на элемент KV, а множитель 2 учитывает key и value. Для FP16 и BF16 q = 2, для FP8 обычно q = 1, но фактический формат и служебные данные нужно брать из конфигурации конкретного движка.

При GQA число query heads и число KV-голов различаются. Это не косметическая деталь. Если в формулу поставить все query heads, вы можете заложить в сеть в несколько раз больше трафика, чем существует в реальности. Если модель использует MLA, sliding window attention или собственное представление cache, стандартная формула тоже не годится. Снимите фактический размер выделенного cache на коротком и длинном префиксе, затем получите наклон по токенам.

Блоки меняют расчет. Если движок хранит cache блоками по B токенов, удаленно полезны только полные совпавшие блоки. Для совпадения в Tmatch токенов берите:

Tполезных = floor(Tmatch / B) × B
Sload = байт_на_токен × Tполезных

Хвост внутри неполного блока придется пересчитать. Не называйте его remote hit только потому, что строковый префикс совпал.

Минимальный замер, который стоит сделать до проектирования

Запустите один и тот же запрос с несколькими длинами неизменяемого префикса: например, 4K, 16K, 32K и 64K токенов. Запишите фактическое выделение KV, время prefill и TTFT при холодном запуске. Повторите на рабочей параллельности, а не на одиноком запросе.

Вам нужна таблица такого вида:

prefix_tokens,kv_bytes,cold_prefill_ms,local_hit_ttft_ms
4096,...,...,...
16384,...,...,...
32768,...,...,...
65536,...,...,...

Разность kv_bytes между соседними строками, деленная на разность токенов, даст реальный байтовый наклон. Разность cold_prefill_ms покажет фактическую цену повторного чтения префикса на вашей GPU и вашем batch size. Это надежнее любого калькулятора из чужого кластера.

Удаленная загрузка должна быть быстрее пересчета с запасом

Сравнивать нужно полное время получения пригодных KV-блоков, а не только число гигабайт, деленное на заявленные гигабиты в секунду.

Для одного удаленного попадания используйте:

Tremote = Tlookup + Tqueue + Sload / Beff + Tstage + Tinstall
Trecompute = Tdispatch + Tprefill(Tполезных)

Tlookup включает поиск хеша и метаданных. Tqueue - ожидание сетевого канала, staging-буфера или удаленного сервера. Beff - полезная пропускная способность именно для KV-передачи при той же конкуренции. Tstage учитывает промежуточную копию через CPU-память, если путь не прямой. Tinstall включает размещение блоков в cache приемника и любые проверки совместимости.

Условие выигрыша выглядит скучно, но оно защищает от дорогих ошибок:

Tremote + Tinterference < Trecompute

Tinterference - время, которое чужая загрузка отняла у активных запросов. Оно часто не видно в трассе одного запроса. Например, загрузка длинного remote prefix заняла DMA-канал или съела свободные GPU-блоки, после чего текущий decode начал ждать освобождения памяти. Формально remote hit был успешен. Пользователь все равно увидел худшую задержку.

Не берите скорость сетевой карты из спецификации. На 100 GbE теоретический потолок после перевода единиц выглядит большим, но application-level поток легко ограничат PCIe, NUMA, TCP, копирование в pinned memory, размер сообщения и конкурирующие передачи. В multi-tier offloading vLLM отдельно предупреждает, что вторичные уровни не обращаются к GPU напрямую: передача идет через CPU primary tier. Такой путь надо измерять как два участка, а не как один сетевой линк.

Практическое правило: включайте remote load только если на целевой длине префикса его p95 заметно ниже p50 холодного prefill. Равенство средних не годится. При очереди и редком всплеске сеть почти всегда проигрывает хвостом.

Сетевую цену определяет не hit rate, а поток байтов

Hit rate без размера префикса скрывает главное. Тысяча попаданий по 256 токенов и десять попаданий по 64K токенов дают совершенно разную нагрузку, хотя в панели оба случая могут выглядеть как «cache hit».

Считайте входящий и исходящий поток отдельно:

Rread = λ × hremote × E[Sload]
Rwrite = λ × hstore × E[Sstore]
Rtotal = Rread + Rwrite + Rreplica

Здесь λ - входящий поток запросов, hremote - доля запросов с успешным удаленным чтением, hstore - доля запросов, которые создают блоки, достойные публикации в общем пуле. Rreplica добавляется, если вы копируете один блок в несколько мест или делаете фоновую репликацию.

Самая вредная настройка - публиковать в общий пул каждый законченный KV-блок. Большая часть пользовательских хвостов уникальна. Вы платите за запись, индексирование и вытеснение, а повторного чтения не будет. Публикуйте только блоки, для которых есть причина ожидать повтор: фиксированные системные инструкции, общие документы, стабильные multi-turn истории или заранее определенные префиксы приложений.

Сетевой бюджет надо считать и на пике. Возьмите худший пятнадцатиминутный интервал, а не среднее за сутки. Затем проверьте две картины нагрузки:

  • регулярные удаленные попадания по длинным префиксам;
  • одновременный запуск новых реплик, которые начинают тянуть один и тот же набор горячих блоков;
  • всплеск запросов, где cache miss заставляет и пересчитывать префикс, и одновременно публиковать новые блоки;
  • деградация одного storage-узла, когда трафик переходит на оставшиеся узлы.

Последний сценарий особенно неприятен. Если remote load становится медленнее prefill, система может одновременно насытить сеть и вернуть GPU повторную работу. График GPU utilization при этом выглядит занятым, но полезная пропускная способность падает.

Вытеснение измеряют reuse distance, а не TTL

Не привязывайте эксперимент к вендору
Сохраняйте единый клиентский интерфейс, пока проверяете, где remote cache действительно оправдан.

Блок живет в пуле не столько секунд, сколько позволяет его относительная популярность и доступная емкость. TTL задает верхнюю границу жизни. Он не гарантирует, что cache сохранится до повторного запроса.

Для каждого префикса или группы одинаковых префиксов измерьте reuse distance: объем уникальных KV-блоков, записанных или затребованных между двумя обращениями к этому же префиксу. Если между обращениями прошло 300 GB уникального рабочего набора, а в общем пуле реально доступно 120 GB после резерва, повторное чтение почти не случится при LRU-подобной политике.

Эффективная емкость не равна физической:

Ceffective = Cphysical - Cactive_decode - Creserved - Cfragmentation

Cactive_decode нельзя считать свободным. Длинные активные генерации удерживают блоки, и их внезапное вытеснение может испортить качество обслуживания прямо сейчас ради гипотетического будущего hit. Cfragmentation появляется из-за размеров блоков, несоответствия шардов, неполных страниц и служебных структур.

Держите отдельные метрики для трех причин промаха: блок никогда не публиковали, блок был опубликован, но его вытеснили, блок найден, но его нельзя загрузить из-за несовместимости или ошибки транспорта. Одна метрика «miss» смешивает продуктовую проблему с проблемой емкости и сетевой проблемой.

Рекомендация «поставьте TTL побольше» популярна потому, что она проста. Обычно она неверна. Большой TTL на уникальных префиксах превращает общий пул в склад одноразовых данных, выдавливает действительно повторяемые блоки и увеличивает давление на каталог. TTL должен следовать наблюдаемому интервалу повторного использования, а не надежде, что когда-нибудь тот же документ снова придет.

Запуск новой реплики меняет математику сильнее, чем кажется

Новая реплика полезна только тогда, когда она может принять запрос и обработать его без нового узкого места. Пустой локальный cache делает ее холодной. Общий KV-cache может сократить этот холодный период, но при неудачной политике запуска превратит autoscaling в массовую синхронную загрузку.

Представьте десять новых decode-реплик после скачка трафика. Все они получают похожие запросы с длинной системной инструкцией и общей базой контекста. Если балансировщик раздает запросы равномерно, каждая реплика пытается прочитать одинаковые блоки. Вы создали fan-out чтений, хотя одна старая теплая реплика уже держала эти данные локально.

Нужны разные правила для первых минут жизни реплики:

  1. Ограничьте число параллельных remote load на новой реплике и глобально на общий пул.
  2. Направляйте первые повторяющиеся запросы на теплые экземпляры, пока новая реплика не получила нужный базовый набор.
  3. Прогревайте только измеренные горячие префиксы, а не случайные недавние запросы.
  4. Считайте readiness по способности обслужить целевой профиль, а не по факту поднятого процесса.
  5. Оставьте пересчет как управляемый fallback, если очередь на удаленную загрузку растет.

«Прогреть cache» тоже требует расчета. Если вы переносите W байт на N реплик за окно Δt, только этот прогрев потребует примерно N × W / Δt полезной пропускной способности, без учета нормального трафика. Если канал не выдерживает, префилл на части новых реплик может быть дешевле и быстрее, чем координированная заливка.

В работе Mooncake описана KVCache-ориентированная архитектура с раздельными кластерами prefill и decoding и планировщиком, который учитывает пропускную способность и SLO. Из этого не следует, что любой общий cache надо строить так же. Следует другое: cache становится ресурсом планировщика, а не прозрачной библиотекой под приложением.

Частичный hit часто выгоднее полного, но только при правильной границе

Проверяйте гипотезы без переписывания клиента
Сохраните SDK, код и промпты, когда меняете модельный маршрут для нагрузочных измерений.

У длинного запроса совпадает не весь префикс. Системная инструкция может быть общей, затем идет одинаковый RAG-набор, а затем персональная история пользователя. Если движок умеет взять первые полные совпавшие блоки и пересчитать хвост, partial hit может убрать большую часть prefill.

Для такого случая считайте отдельно:

Ttotal = Tremote(Tcommon) + Tprefill(Ttail) + Tdecode

Не переносите персональные блоки только потому, что они длинные. Это плохой кандидат по двум причинам. Во-первых, они редко повторятся на другой реплике. Во-вторых, общий пул расширяет область доступа к данным. Граница публикации должна совпадать с границей, где данные действительно допускают повторное использование по политике безопасности и tenancy.

В банке, healthcare или госсекторе особенно опасна идея хешировать «весь prompt и хранить его везде». Хеш не отменяет того, что сам KV-cache кодирует состояние обработки входного текста. Разделяйте cache namespace по модели, версии токенизатора, параметрам attention, адаптеру, tenant и классу данных. Не пытайтесь компенсировать отсутствие изоляции длинным TTL или случайным префиксом ключа.

Правильная граница для общего блока обычно находится до пользовательских атрибутов: общая инструкция, утвержденный шаблон, версия базы знаний, обезличенный документ. Дальше лучше оставить локальный cache или пересчет.

Проверяйте совместимость до поиска выгоды

Измеряйте задержку в нужном контуре
Используйте модели AI Router, когда для эксперимента важны data residency и низкая задержка.

KV-блоки не являются универсальным результатом для «похожей модели». Они зависят от точной модели, весов, токенизатора, позиции токенов, формата cache, реализации attention и иногда от подключенного адаптера. Два endpoint с одинаковым маркетинговым именем модели могут быть несовместимы для обмена KV.

Ключ cache должен включать как минимум идентификатор модели и ревизии, хеш токенов префикса, параметры формирования позиции, формат KV, размер блока и namespace арендатора. Если используется LoRA или другой адаптер, включайте его версию. Если меняете системный prompt, не рассчитывайте на текстовое сходство: изменившийся токен в начале сдвигает весь дальнейший префикс.

Проверяйте и детерминизм сборки prompt. Часто команда винит cache, а источник промахов проще: один сервис вставляет дату, другой меняет порядок инструментов, третий сериализует JSON иначе. Визуально промпты одинаковы. Последовательности токенов различаются, значит совпадения нет.

В vLLM существуют коннекторы и конфигурация ролей producer, consumer и both, а политика ошибки загрузки может быть recompute либо fail. Это полезное напоминание: транспортный путь должен быть частью контракта обслуживания, с таймаутом, лимитами и наблюдаемым fallback, а не обязательной зависимостью между генерацией и удаленным cache.

Решение принимают по ожидаемой цене запроса

Сведите измерения в одну модель до внедрения. Не нужна идеальная симуляция кластера. Нужна честная оценка, которая показывает, где изменяется знак выгоды.

Для класса запросов i запишите:

E[выгода_i] = hremote_i × (Trecompute_i - Tremote_i)
              - Pstall_i
              - Pwrite_i
              - Peviction_i

E[выгода_всего] = Σ λi × E[выгода_i]

Pstall выражайте в миллисекундах хвостовой задержки или в стоимости потерянной производительности, но не делайте вид, что его нет. Pwrite включает публикацию блоков, которые затем никто не прочитал. Peviction отражает цену вытеснения более полезных данных. Если вы не можете оценить два последних члена, хотя бы проведите нагрузочный тест с ограниченной емкостью и с реальной смесью длин запросов.

Полезный рабочий эксперимент занимает не месяцы. Возьмите один класс повторяемых префиксов, включите read-only shared cache без публикации новых хвостов, задайте строгий лимит на удаленные загрузки и соберите четыре распределения: холодный prefill, local hit, remote hit и fallback recompute. Затем увеличивайте допустимую долю remote reads, пока p95 TTFT и throughput не покажут точку, где сеть начинает вредить.

AI Router подходит для такого эксперимента как единый OpenAI-совместимый шлюз: можно сохранить код клиента, изменить маршрут модели и раздельно оценить профили задержки для размещенных моделей и внешних провайдеров. Но общий KV-cache имеет смысл проектировать только там, где вы контролируете исполнение, память и сетевой путь, а не поверх непрозрачного удаленного inference endpoint.

Не начинайте с покупки еще одного слоя хранения. Сначала возьмите трассу реальных префиксов, посчитайте байты на токен, измерьте полезную пропускную способность и разложите промахи по причинам. После этого станет видно, нужен ли вам общий KV-cache, более умная маршрутизация на теплые реплики или просто достаточный локальный cache.

Часто задаваемые вопросы

Когда общий KV-cache между репликами действительно окупается?

Общий KV-cache оправдан, когда много запросов повторяют длинные идентичные блоки, но попадают на разные реплики. Типичные источники совпадений: системный промпт, длинная история диалога, неизменяемый корпус RAG и шаблоны tool calling. Если совпадение начинается только после персональных данных пользователя или меняется почти на каждом запросе, сеть будет переносить блоки, которые редко окупаются.

Как посчитать размер KV-cache для одного префикса?

Считайте байты на токен как 2 × число слоев × число KV-голов × размер головы × байты элемента. Затем умножьте результат на число совпавших токенов, округленное до размера блока. Для моделей с GQA нужно брать именно число KV-голов, а не число attention heads, иначе оценка выйдет завышенной.

Какую сетевую пропускную способность использовать в расчете?

Нельзя сравнивать паспортную скорость сетевой карты с расчетом. Нужна полезная скорость конкретного пути GPU, память хоста, сетевой стек, удаленное хранилище и обратно в GPU, измеренная на блоках того же размера. В расчет также входит задержка первого блока и просадки при параллельных загрузках.

Может ли общий KV-cache ухудшить задержку?

Да, и это одна из самых частых ошибок в проекте. При высокой конкуренции удаленная загрузка может ждать очередь, занять буфер или вытеснить горячие KV-блоки локальных decode-запросов. Поэтому сравнивайте не только средний TTFT, но и p95 и p99 по всей смешанной нагрузке.

Помогает ли общий KV-cache при запуске новых реплик?

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

Чем общий KV-cache отличается от локального prefix cache?

Локальный prefix cache повторно использует KV-блоки в памяти одной реплики. Общий cache делает эти блоки доступными другой реплике или отдельному prefill-воркеру. Первый вариант проще и почти всегда должен быть включен раньше, второй нужен только тогда, когда маршрутизация и масштабирование разбивают повторяющийся трафик между экземплярами.

Как учесть вытеснение KV-блоков из общего пула?

Вытеснение оценивают не средним возрастом записи, а reuse distance: сколько уникальных байтов или блоков проходит между двумя обращениями к одному префиксу. Если этот объем больше эффективной емкости пула после резерва для активных decode-запросов, блок почти наверняка не доживет до повторного чтения. TTL полезен как предохранитель, но он не заменяет измерение reuse distance.

Нужен ли общий KV-cache маленькому кластеру?

Не всегда. Сначала проверьте более дешевые причины: неверный sticky routing для диалогов, слишком маленький локальный cache, несогласованный хеш префикса, разные версии токенизатора или модели, агрессивный autoscaling. Общий слой хранения добавляет сетевую зависимость, управление емкостью и новую очередь, поэтому его вводят после того, как локальная повторная работа уже измерена.

Чем shared cache отличается от передачи KV между prefill и decode?

Это разные операции с разным профилем нагрузки. При prefill-decode disaggregation KV переносится почти для каждого запроса между ролями, даже когда следующий запрос не повторяет префикс. При shared prefix cache сеть работает только на промахах локального cache и удаленных попаданиях, зато выгода зависит от повторяемости и времени жизни блоков.

Что делать, если удаленный KV-cache недоступен?

Не включайте удаленное чтение как обязательный путь без плана отказа. Нужен измеримый timeout, проверка совместимости блоков, ограничение одновременных загрузок и понятная политика: пересчитать префикс или отклонить запрос. Для пользовательского сервиса почти всегда разумнее пересчитать префикс, чем превращать временную проблему хранилища в массовые ошибки.