Один длинный промпт тормозит всех: как инференс LLM растаскивают по разным видеокартам

Один длинный промпт тормозит всех: как инференс LLM растаскивают по разным видеокартам

Если в чате с нейросетью ответ печатался ровно, а потом замер на секунду и поехал дальше, интернет тут обычно ни при чём. В этот момент на сервере кто-то прислал модели документ на десять тысяч токенов, и ваша генерация встала в очередь. Техника, которая это лечит, называется Prefill-Decode Disaggregation. Подробный разбор выпустил Amit Shekhar из Outcome School (https://x.com/amitiitbhu/status/2102260791298986323), ниже пересказ с комментариями.

Две фазы, которые ведут себя по-разному

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

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

Prefill упирается в вычисления, decode в память

Prefill нагружает вычислительные блоки: тысячи токенов обрабатываются одновременно, GPU работает на полную. Decode нагружает шину памяти: математики на один токен совсем мало, зато на каждом шаге приходится вычитывать из памяти все веса модели и весь KV-кэш. Вычислительные ядра при этом простаивают в ожидании данных.

Две фазы эксплуатируют два разных ресурса одного и того же чипа. Prefill ограничен тем, как быстро GPU считает. Decode ограничен тем, как быстро GPU достаёт данные из памяти. Отсюда растёт всё остальное.

Что ломается, когда обе фазы живут на одной карте

В классической схеме, её называют co-located serving, одна и та же видеокарта обслуживает обе фазы для всех запросов, а сами запросы собираются в батчи. Пока десять пользователей спокойно получают свои токены, приходит одиннадцатый с документом на десять тысяч токенов. Карта уходит в тяжёлый prefill на целую секунду, и всё это время у остальных ответ стоит на месте. Эффект называется generation stall.

Работает это и в обратную сторону. Если GPU занят генерацией для большой пачки пользователей, новый запрос ждёт своей очереди на чтение промпта, и первый токен приходит с опозданием.

Измеряют происходящее двумя метриками. TTFT, time to first token, показывает, сколько проходит от нажатия Enter до появления первого слова, и зависит от prefill. TPOT, time per output token, показывает, насколько ровно идёт текст дальше, и зависит от decode. На одной карте эти метрики тянут одеяло друг у друга: приоритет prefill улучшает TTFT и портит TPOT, приоритет decode даёт обратную картину.

Почему простые решения не спасают

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

Второй ход поумнее: chunked prefill. Длинное чтение промпта режут на куски и подмешивают по кусочку в каждый батч генерации. Никто надолго никого не блокирует, зато каждый шаг decode теперь тащит на себе довесок, так что TPOT подрастает у всех. Prefill, разбитый на части, финиширует позже, и TTFT тоже подрастает. Боль размазывается по системе.

Идея дизагрегации

Disaggregation переводится как расцепление. Смысл в том, чтобы отправить prefill и decode на физически разные GPU. Одна группа карт занимается только чтением промптов, вторая только написанием ответов. Драться за один чип им больше негде.

В конструкции четыре элемента. Роутер принимает запрос и решает, какой prefill-воркер и какой decode-воркер его обслужат. Prefill-воркеры читают промпт, отдают первый токен и собирают KV-кэш, их подбирают под максимальные вычисления. Decode-воркеры принимают готовый кэш и штампуют токены, им нужна быстрая и объёмная память. Четвёртый элемент связывает первые три: передача KV-кэша по сети от одного воркера к другому.

Как проходит один запрос

Возьмём промпт на 4000 токенов и ответ на 200 токенов, цифры условные. Роутер выбирает наименее загруженный prefill-воркер и резервирует слот на decode-воркере. Prefill-воркер обрабатывает все 4000 токенов за один проход, примерно за 400 миллисекунд, и получает первый токен плюс KV-кэш размером около двух гигабайт. Первый токен сразу уходит пользователю, TTFT составил те самые 400 миллисекунд.

Дальше два гигабайта кэша копируются на decode-воркер. По быстрому интерконнекту это 20-50 миллисекунд, а в реальных системах передачу запускают кусками ещё во время prefill, чтобы спрятать задержку целиком. Decode-воркер поднимает кэш к себе и выдаёт токены по 20 миллисекунд каждый, 200 токенов укладываются примерно в 4 секунды.

Самое важное происходит параллельно. Prefill-воркер освободился сразу после передачи кэша и уже читает следующий промпт. Decode-воркера никто не прервёт, потому что prefill на этой карте не запускается в принципе.

Что это даёт и чего стоит

Масштабировать фазы теперь можно по отдельности. Длинные промпты и короткие ответы означают, что докупать надо prefill-воркеры, короткие промпты и длинные ответы означают обратное. Железо под каждую фазу подбирается своё: под decode часто берут карты с большой памятью, которые обходятся дешевле топовых вычислительных. Батчи на decode-воркерах получаются плотнее, потому что все запросы в них делают один и тот же мелкий шаг, и цена за токен падает. Задержки становятся предсказуемыми, случайных замираний посреди ответа пользователь больше не видит.

Платить приходится трафиком и сложностью. KV-кэш гоняется по сети на каждом запросе, на длинных промптах это гигабайты, так что без NVLink, InfiniBand или RDMA вся экономия уходит в передачу. Архитектура усложняется: роутер, два типа воркеров, транспорт кэша и обработка отказов для каждого из них. Если decode-воркер падает посреди ответа, кэш теряется и запрос приходится переигрывать с нуля. Добавьте риск простоя: в момент, когда длинных промптов нет, prefill-воркеры скучают, а decode-воркеры захлёбываются, и за балансом надо следить руками.

Кому это нужно

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

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

Из коробки такой режим умеет, например, SGLang, так что собирать роутер и транспорт вручную сегодня уже не обязательно.

Ссылки по теме

Prefill против decode: https://outcomeschool.com/blog/prefill-vs-decode-llm-inference-optimization
Как устроен KV-кэш: https://outcomeschool.com/blog/kv-cache-in-llms
Как работает token streaming: https://outcomeschool.com/blog/how-does-token-streaming-work
Как GPU считает нейросети: https://outcomeschool.com/blog/how-does-a-gpu-work-for-deep-learning
Continuous batching в LLM: https://outcomeschool.com/blog/continuous-batching-in-llms
Как устроен SGLang: https://outcomeschool.com/blog/how-does-sglang-work
Обзор техник оптимизации инференса: https://outcomeschool.com/blog/llm-inference-optimization
Вопросы для собеседований по AI Engineering: https://github.com/amitshekhariitbhu/ai-engineering-interview-questions