Головна / Статті / Чому 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 щоразу, коли блок створюється чи витісняється. У результаті маршрутизатор має глобально правильну карту блоків токенів і їхнього розташування. Точність повна, складні схеми витіснення й дезагреговані розгортання обробляються правильно, хоча компонентів більше.

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

П: Чи проблема, що 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