Главная / Статьи / Почему round-robin подводит парки LLM и как llm-d маршрутизирует по кэшу и очереди

Почему round-robin подводит парки LLM и как llm-d маршрутизирует по кэшу и очереди

Балансировка round-robin впустую тратит префиксные кэши и игнорирует размер запроса. Как llm-d оценивает реплики, разделяет prefill и decode и когда всё это вообще нужно.

2438 слов

Обслуживать большую языковую модель на одной машине — задача в целом решённая. Проблемы другого класса начинаются, когда её нужно отдать всему миру. В этой статье разберём, что ломается, когда много GPU-серверов стоят за обычным балансировщиком нагрузки, и что с этим делает llm-d.

Основы вкратце

Несколько фактов с нижних уровней объясняют всё, что будет дальше.

Начнём с модели: это просто большой файл с весами. GPU подходят для неё, потому что эта арифметика отлично распараллеливается. VRAM нужна потому, что системная RAM примерно в 52 раза медленнее, чем требуется для загрузки этих ядер. Каждый запрос делится на prefill, который упирается в вычисления и даёт начальную паузу (время до первого токена), и decode, который упирается в пропускную способность памяти и даёт поток (время на каждый выходной токен). В decode каждый токен стоит полного повторного чтения всей модели.

Поверх этого железа лежит программный слой:

  • KV-кэш хранит то, что вычислил prefill, чтобы decode не пересчитывал это заново. Именно поэтому начальная пауза случается лишь один раз.
  • Он занимает около 128 КБ на токен для модели на 8B. Поэтому число одновременных пользователей ограничивает память под кэш, а не голые вычисления. Примерно 60 ГБ на H100 вмещают около 490 000 токенов.
  • PagedAttention обращается с этим пулом так же, как операционная система с виртуальной памятью. Фрагментация исчезает, а префикс, общий для нескольких запросов, хранится лишь один раз.
  • Префиксное кэширование позволяет блокам пережить породивший их запрос; ключом служит хэш их содержимого. К десятому ходу чата работы примерно столько же, сколько было на втором.
  • Непрерывный батчинг делит стоимость чтения весов между всеми ожидающими. Чтение KV разделить нельзя: оно принадлежит каждой последовательности и повторяется на каждом шаге. Пропускная способность может вырасти в десять раз и больше, а задержка для каждого пользователя растёт с длиной контекста: около 4,8 мс на токен в одиночку и около 22 мс, когда пул кэша заполнен.
  • Тензорный, конвейерный и экспертный параллелизм делят одну модель между многими GPU, когда одна карта её не вмещает.

У всех этих оптимизаций есть общий подвох: каждая из них — это состояние отдельного сервера.

В: Что идёт не так в парке серверов?

Настоящий сервис не может жить на одной машине. Чтобы обслуживать весь мир, нужно много серверов, у каждого свои GPU и полная копия модели, а перед ними — балансировщик нагрузки:

[ your message ] ──▶ [ load balancer ] ──▶ [ Server 2 ]
                       round-robin      ──▶ [ Server 5 ]
                                        ──▶ [ Server 9 ]

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

Сбой 1: кэш разбрасывается

Приходит первый ход разговора, и round-robin отправляет его на сервер 2. Этот сервер делает prefill диалога, строит KV-кэш, стримит ответ и благодаря префиксному кэшированию сохраняет блоки.

Затем приходит второй ход, и round-robin направляет его на сервер 5. Эта машина никогда не видела этот разговор, поэтому весь диалог проходит prefill заново, с нуля. Пригодный кэш на сервере 2 остаётся нетронутым: за него заплачено, а он простаивает, пока что-нибудь его не вытеснит.

В парке со случайной балансировкой доля попаданий в префиксный кэш близка к нулю. Самая ценная оптимизация во всём стеке выбрасывается на уровне маршрутизации.

Сбой 2: каждый запрос выглядит одинаково дорогим

Round-robin предполагает, что каждый запрос стоит примерно одинаково и по времени, и по памяти. Это допущение пришло из обычного веб-обслуживания, где оно достаточно близко к правде. Загрузка страницы профиля, рендеринг шаблона и индексированный запрос к базе укладываются в десятки миллисекунд и занимают при этом несколько килобайт. Когда все запросы такие маленькие и однородные, почти неважно, какой сервер какой получит, и подсчёт соединений вполне годится для распределения нагрузки.

LLM ломает обе половины этого допущения сразу:

  • один пользователь набирает короткий вопрос примерно на 40 токенов
  • второй загружает 60-страничный PDF, это примерно 50 000 токенов

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

Round-robin выдаёт каждому из них по одному слоту. Хуже того, запросы встают в очередь за тем, кто пришёл первым. Одна машина может быть завалена тремя гигантскими prefill, пока соседняя простаивает, а балансировщик, который вслепую крутит по кругу или в лучшем случае считает открытые соединения, видит идеально ровное распределение.

В документации llm-d сказано, что универсальные балансировщики игнорируют структуру промпта, число токенов, глубину очереди и загрузку GPU. В масштабе парка это не мелкая неэффективность. Она перекрывает всё, что было настроено в остальном.

В: Что делал бы маршрутизатор, понимающий LLM?

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

Именно эту задачу решает llm-d. Это распределённый стек инференса, изначально построенный для Kubernetes, проект CNCF Sandbox, который начали Red Hat, Google Cloud, IBM Research, CoreWeave и NVIDIA. В роли движка выступает vLLM, а llm-d координирует множество его экземпляров.

Запросы входят через Inference Gateway, который состоит из прокси и Endpoint Picker (EPP) — той части, что решает, куда пойдёт каждый запрос. EPP оценивает каждую реплику-кандидата по сигналам, которых у обычного балансировщика нет:

  • Совпадение с префиксным кэшем: какая доля этого промпта уже закэширована на реплике
  • Глубина очереди: сколько работы уже ждёт на ней
  • Заполненность KV-кэша: близка ли она к пределу и вот-вот начнёт вытеснение
  • Прогнозируемая задержка: необязательная модель XGBoost в llm-d, оценивающая, сколько этот запрос займёт там

Пример с тремя репликами показывает компромиссы. Реплика B полностью простаивает, но не хранит ничего полезного, так что её выбор означает полный холодный prefill. Реплика C совпадает на 88% префикса, но перед запросом 31 запрос в очереди, а значит, на ожидании потеряется больше, чем сэкономится. Реплика A совпадает на 92% при всего 2 запросах в очереди и побеждает. Балансировщик round-robin не способен прийти к такому решению, потому что оно зависит от того, что хранит каждый сервер.

В: Как маршрутизатор узнаёт, что закэшировано?

Существует два подхода, и они разменивают точность на простоту.

Приблизительный. Маршрутизатор режет каждый промпт на блоки фиксированного размера и строит над ними цепочку скользящих хэшей. В LRU-индексе в памяти он запоминает, какие хэши префиксов на какой под отправил. Такой подход очень лёгкий: не нужны ни токенизатор, ни дополнительный сервис. Минус в том, что он угадывает состояние сервера по собственным записям, и догадка расходится с реальностью всякий раз, когда реплика вытесняет блоки, не сообщая маршрутизатору.

Точный. Здесь маршрутизатор получает точную токенизацию от render-эндпоинта vLLM, а vLLM публикует события KV-кэша через ZMQ при каждом создании или вытеснении блока. В итоге у маршрутизатора есть глобально верная карта блоков токенов и их расположения. Точность полная, сложные схемы вытеснения и дезагрегированные развёртывания обрабатываются правильно, хотя компонентов больше.

Заявленный выигрыш от правильного решения — примерно в 3 раза выше пропускная способность на выходе и в 2 раза быстрее время до первого токена. Он берётся не из нового железа, а из того, что запросы отправляются на машину, которая уже проделала работу.

В: Проблема ли, что prefill и decode делят один GPU?

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

Пользователи чувствуют сбой напрямую. Представьте пятьдесят человек посреди ответов, все плавно получают токены. Затем на prefill приходит промпт на пятьдесят тысяч токенов и монополизирует GPU на сотни миллисекунд, и все пятьдесят потоков замирают. Один тяжёлый запрос ухудшает жизнь всем, а планировщик батчей не поможет, потому что обеим задачам действительно нужен один и тот же кремний.

Есть и несоответствие формы ресурсов. У GPU, подобранных под prefill, пропускная способность оказывается избыточной, а GPU, подобранным под decode, не хватает FLOPs. Один пул не может подойти обоим.

Решение: два пула для двух противоположных задач

Дезагрегация делит парк надвое:

  • Пул prefill рассчитан на вычисления. Он читает промпты, строит KV-кэши и передаёт их дальше.
  • Пул decode рассчитан на пропускную способность памяти. Он принимает кэши и стримит токены большими батчами, без прерываний на prefill.

Пулам нужно обмениваться KV-кэшем, то есть копировать его из памяти одного GPU в память другого. Этим занимается NIXL, NVIDIA Inference Xfer Library: она перемещает тензоры кэша по RDMA или InfiniBand, NVLink, NVMe или TCP на полной скорости канала. От этого транспортного слоя зависит практичность всей схемы. Более медленная передача обошлась бы дороже, чем пересчёт кэша.

Награда в том, что пулы масштабируются независимо:

Нагрузка Типичные примеры Куда добавлять мощность
Длинные промпты, короткие ответы Суммаризация, RAG, code review GPU для prefill
Короткие промпты, длинные ответы Чат, агенты GPU для decode

llm-d сообщает о до 70% большем числе токенов в секунду только от этого разделения, а дезагрегацию часто описывают как мгновенный прирост ~25% в типичных развёртываниях.

В: Что ещё живёт на уровне парка?

С маршрутизатором, понимающим инференс, и специализированными пулами становится возможным ещё несколько вещей.

Многоуровневая выгрузка KV. VRAM — самый дефицитный ресурс, поэтому неиспользуемые сейчас блоки кэша могут опускаться в RAM процессора, а затем на локальный SSD и возвращаться, когда появляется подходящий запрос. Этот уровень намного медленнее VRAM, но всё равно с большим отрывом выигрывает у повторного prefill. Для нагрузок с интенсивным кэшированием llm-d сообщает о 13,9-кратной пропускной способности от иерархической выгрузки.

Автомасштабирование с учётом SLO. По умолчанию Kubernetes масштабирует по загрузке GPU, а здесь это почти ничего не значит: GPU может быть занят на 100%, выполняя бесполезный пересчёт. llm-d масштабирует по тому, что действительно обещано: целевым значениям времени до первого токена и задержки между токенами. Он также умеет масштабировать пулы prefill и decode по отдельности, что возможно только благодаря дезагрегации.

Широкий экспертный параллелизм. В моделях Mixture-of-Experts эксперты распределяются по многим GPU, так что работают только те, к которым направлен токен. Именно это делает возможным обслуживание моделей на 671B параметров, и llm-d приводит около 50 тысяч токенов в секунду пропускной способности кластера на этом пути.

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

Осторожно с цифрами. Все эти данные взяты из собственных бенчмарков проекта v0.7, запущенных на «well-lit paths» (хорошо освещённых путях) — проверенных эталонных конфигурациях. Считайте их верхними границами в хороших, тщательно настроенных условиях, а не обещаниями. Реальные результаты зависят от формы трафика. Если у промптов нет общих префиксов, маршрутизация с учётом кэша ничего не даёт, поэтому стоит сначала измерить реальную нагрузку.

В: Нужно ли всё это?

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

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

Сначала измеряйте: узкое место редко оказывается там, где его предполагают.

Весь стек как цепочка следствий

  1. GPU нужен потому, что прямой проход — это миллиарды независимых умножений, а CPU перемалывает их последовательно.
  2. VRAM нужна потому, что системная RAM примерно в 52 раза медленнее, чем нужно, чтобы снабжать тысячи ядер.
  3. VRAM к тому же мала, и это превращает память в дефицитный ресурс, ограничивающий все слои выше.
  4. vLLM нужен потому, что скрипт на PyTorch сам по себе не сервис.
  5. KV-кэш нужен потому, что повтор prefill для каждого токена вызывал бы паузу перед каждым словом.
  6. PagedAttention нужен потому, что наивное выделение KV тратит впустую большую часть памяти.
  7. Префиксное кэширование нужно потому, что чат на каждом ходу заново отправляет весь диалог.
  8. Непрерывный батчинг нужен потому, что одно чтение весов должно обслуживать всех ожидающих, а не одного человека.
  9. Параллелизм нужен потому, что модель переросла карту.
  10. llm-d нужен потому, что балансировка round-robin выбрасывает пункт 7 и подрывает пункт 8, как только появляется второй сервер.

Что происходит, когда вы нажимаете Enter в чат-приложении

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

Выбранная реплика находит большую часть диалога в кэше с прошлого хода, так что prefill нужен только для нового сообщения. Если парк разделён, эта работа попадает на пул GPU, выделенный под prefill, а получившийся KV-кэш затем едет по RDMA в пул decode.

В пуле decode запрос попадает в батч с десятками незнакомцев. Каждые несколько миллисекунд вся модель читается из VRAM ровно один раз, и это одно чтение даёт следующий токен для всех сразу. Кэш растёт на 128 КБ, батч пересобирается, и цикл повторяется.

Несколько сотен повторов — и на экране появляются слова.

За этим ответом стоит решение, которое неоправданно сложно построить.

Где почитать подробнее

  • репозиторий llm-d с эталонными путями и результатами бенчмарков v0.7
  • сайт проекта llm-d с документацией по архитектуре
  • документация vLLM с заметками о дизайне префиксного кэша
  • репозиторий NIXL, где лежит библиотека передачи KV