Как работает vLLM?
В этом блоге мы разберём, как работает vLLM. Также посмотрим, зачем он нужен, как он так умно управляет памятью и где применяется в реальном мире, чтобы обслуживать большие языковые модели для множества пользователей одновременно. Мы рассмотрим следующее: - Что значит обслуживать LLM - Краткое повто
Источник: https://x.com/amitiitbhu/status/2069384034074107905
Автор: Amit Shekhar
В этом блоге мы разберём, как работает vLLM. Также посмотрим, зачем он нужен, как он так умно управляет памятью и где применяется в реальном мире, чтобы обслуживать большие языковые модели для множества пользователей одновременно.
Мы рассмотрим следующее:
Я Amit Shekhar, основатель Outcome School. Я обучал и менторил многих разработчиков, и их усилия помогли им получить высокооплачиваемую работу в IT, помогли многим технологическим компаниям решать их уникальные задачи и привели к созданию множества open-source библиотек, которые используют ведущие компании. Я увлечён тем, чтобы делиться знаниями через open-source, блоги и видео.
- Что значит обслуживать LLM
- Краткое повторение: prefill, decode и KV-кэш
- Проблема: KV-кэш съедает память GPU
- Почему наивное обслуживание тратит память впустую
- Что такое vLLM
- PagedAttention, ключевая идея
- Как PagedAttention разделяет память
- Непрерывный батчинг
- OpenAI-совместимый API-сервер
- Преимущества vLLM
- vLLM в реальном мире
Я преподаю AI и Machine Learning в Outcome School.
Давайте начнём.
Что значит обслуживать LLM
Прежде чем говорить о vLLM, сначала нужно понять, что значит обслуживать LLM.
Большая языковая модель, или LLM, — это технология, лежащая в основе таких инструментов, как ChatGPT и Claude. Мы даём ей текст, а она возвращает нам текст.
Обслуживать LLM означает запускать модель на компьютере так, чтобы множество пользователей могли одновременно отправлять ей вопросы и получать ответы.
Проще говоря, обслуживание — это часть системы, которая принимает пользовательские запросы, прогоняет их через модель и возвращает ответы.
Допустим, мы построили чат-ассистента. Тысячи людей открывают его одновременно. Каждый человек печатает вопрос. Все эти вопросы приходят в нашу модель, и каждый человек ожидает быстрый ответ. Часть программного обеспечения, которая принимает все эти запросы, запускает модель для каждого из них и возвращает каждый ответ, называется движком обслуживания.
Мы можем представить этот поток так:
SERVING AN LLM
User 1 --question--> +-----------------+ +-----------------+
User 2 --question--> | serving engine |----->| model on GPU |
User 3 --question--> | (takes requests| | (does the heavy |
... | sends replies)|<-----| math, replies) |
User N --question--> +-----------------+ +-----------------+
|
+--reply--> back to each userЗдесь видно, что многие пользователи отправляют вопросы одновременно. Движок обслуживания находится посередине. Он собирает все запросы, прогоняет их через модель на GPU и отправляет каждый ответ нужному пользователю.
Теперь важная часть. LLM работают на специальном чипе, который называется GPU. GPU — это мощный процессор, который очень хорошо справляется с тяжёлыми математическими вычислениями, нужными LLM. GPU стоят дорого, и объём памяти у них ограничен. Поэтому, если мы тратим память GPU впустую, мы можем обслуживать меньше пользователей, а наши расходы растут.
Итак, вся суть обслуживания в следующем: мы хотим обслуживать как можно больше пользователей, как можно быстрее, на одном GPU. Держите эту цель в голове, потому что vLLM создан именно для того, чтобы выиграть эту игру.
Краткое повторение: prefill, decode и KV-кэш
Чтобы понять vLLM, нужно немного разобраться, как LLM генерирует ответ. Не волнуйтесь, всё будет просто.
Когда мы отправляем prompt, модель не читает его как цельные слова. Сначала она разбивает текст на маленькие части, которые называются токенами. Токен — это небольшой фрагмент текста, примерно слово или часть слова. Так prompt превращается в список токенов.
Затем модель работает в две фазы.
Первая фаза — prefill. В этой фазе модель читает весь наш prompt и обрабатывает каждый токен ещё до того, как напишет хотя бы одно слово ответа. Проще говоря, prefill — это когда модель читает и «переваривает» весь наш prompt.
Вторая фаза — decode. В этой фазе модель пишет ответ, по одному токену за раз. Она пишет токен, затем смотрит на всё, что было до этого, затем пишет следующий токен и продолжает, пока ответ не будет завершён.
Во время обеих фаз для каждого токена модель вычисляет некоторые внутренние значения и сохраняет их. Эти сохранённые значения хранятся в так называемом KV-кэше.
Давайте объясним KV-кэш простыми словами. Когда модель читает или пишет каждый токен, она создаёт небольшое резюме этого токена, своего рода заметку о том, что этот токен означает в контексте всего, что было до него. KV-кэш — это коллекция всех этих заметок, по одному набору заметок на токен.
Вот почему KV-кэш так важен. Чтобы написать каждый новый токен во время decode, модели нужны заметки для каждого токена, который был до него. Без KV-кэша модели пришлось бы заново пересчитывать все эти заметки для каждого нового слова. Это было бы мучительно медленно. Поэтому модель один раз сохраняет заметки и переиспользует их. KV-кэш — это то, что делает генерацию длинных ответов быстрой.
Ключевой момент, который нужно запомнить:
KV-кэш растёт по мере роста ответа. Каждый новый токен добавляет ещё один набор заметок в KV-кэш, и все эти заметки находятся в памяти GPU.
Мы можем представить две фазы и растущий KV-кэш так:
PREFILL then DECODE: the KV cache grows one set of notes per token
prompt tokens: [ Tell ][ me ][ a ][ joke ]
| | | |
PREFILL writes: [n] [n] [n] [n] (one note per prompt token)
so KV cache = [n][n][n][n] (4 notes after prefill)
DECODE step 1: writes "Why" KV cache: [n][n][n][n][n]
DECODE step 2: writes "did" KV cache: [n][n][n][n][n][n]
DECODE step 3: writes "the" KV cache: [n][n][n][n][n][n][n]
... (grows by one each step)Здесь можно заметить, что prefill создаёт по одному набору заметок для каждого токена prompt за один проход. Затем decode пишет ответ по одному токену, и каждый новый токен добавляет ещё один набор заметок в KV-кэш. Поэтому чем длиннее ответ, тем больше KV-кэш становится в памяти GPU.
Это фундамент, который нам был нужен. Теперь мы готовы увидеть настоящую проблему.
Проблема: KV-кэш съедает память GPU
Теперь, когда мы знаем, что такое KV-кэш, посмотрим, какие сложности он создаёт.
Сама модель занимает большой кусок памяти GPU. Оставшаяся память используется для хранения KV-кэша всех запросов, которые мы обслуживаем прямо сейчас.
Так что именно KV-кэш определяет, сколько пользователей мы можем обслуживать одновременно. Чем больше свободной памяти под KV-кэш, тем больше запросов мы можем запускать вместе.
Скажем проще. У каждого обслуживаемого запроса есть собственный KV-кэш, и этот KV-кэш продолжает расти по мере роста ответа. Если мы обслуживаем много пользователей, все их KV-кэши одновременно живут в памяти GPU и конкурируют за одно и то же ограниченное пространство.
Поэтому настоящее узкое место при обслуживании LLM — не скорость вычислений. Это память KV-кэша.
Это значит, что весь вызов обслуживания LLM сводится к управлению памятью. Если мы хорошо управляем памятью KV-кэша, мы обслуживаем больше пользователей. Если плохо — растрачиваем GPU и обслуживаем меньше пользователей.
Теперь следующий вопрос: как наивный подход управляет этой памятью и почему это плохо? Давайте посмотрим.
Почему наивное обслуживание тратит память впустую
Давайте разберёмся, как простой, наивный движок обслуживания работает с KV-кэшем и где он ошибается.
Наивный подход делает то, что кажется безопасным, но на самом деле крайне расточительно. Когда приходит запрос, движок не знает, насколько длинным будет ответ. Поэтому, чтобы перестраховаться, он резервирует один большой непрерывный блок памяти, достаточно большой для самого длинного возможного ответа.
Допустим, модель может сгенерировать до 2 000 токенов. Для каждого отдельного запроса наивный движок сразу резервирует место под 2 000 токенов KV-кэша, ещё до того, как модель что-либо написала.
Вот в чём подвох. Большинство ответов короткие. Если ответ пользователя занимает всего 50 токенов, то место для остальных 1 950 токенов просто лежит зарезервированным, но неиспользуемым, и ничего не делает. Мы заблокировали огромный объём памяти под ответ, которому он никогда не был нужен.
У этой потери есть два названия, и нужно понять оба.
Первое — чрезмерное резервирование. Это значит, что мы зарезервировали намного больше памяти, чем запрос реально использовал. Зарезервированное, но неиспользуемое пространство нельзя отдать кому-то ещё, поэтому оно тратится впустую.
Второе — фрагментация. Фрагментация означает, что свободная память разбита на маленькие разрозненные куски, которые мы не можем использовать. Давайте поймём это на картинке.
Мы можем представить наивный подход так:
NAIVE SERVING: one big continuous block reserved per request
Request A: [#### used (50) ............... wasted, reserved for 2000 ..............]
Request B: [###### used (120) ............ wasted, reserved for 2000 ..............]
Request C: [## used (20) ................. wasted, reserved for 2000 ..............]
free memory left: scattered tiny gaps -> cannot fit a new requestЗдесь видно, что каждый запрос захватил гигантский непрерывный блок, но использовал только маленькую часть в начале. Остальное потрачено впустую. А небольшие оставшиеся промежутки между блоками слишком малы и слишком разбросаны, чтобы вместить новый запрос. Поэтому, хотя технически свободной памяти много, мы не можем её использовать. Это и есть фрагментация.
Результат плохой. На бумаге у GPU много памяти, но из-за того, что она потрачена впустую и разбросана, мы можем обслуживать только несколько пользователей одновременно. Мы платим за мощный GPU, а используем лишь малую его часть.
И тут на помощь приходит vLLM.
Что такое vLLM
Теперь, когда мы поняли проблему, давайте поймём решение.
vLLM — это высокопроизводительный движок для обслуживания LLM. Он создан, чтобы обслуживать как можно больше запросов на GPU за счёт очень эффективного управления памятью KV-кэша.
Проще говоря, vLLM — это умный движок обслуживания, который перестаёт тратить память GPU впустую, поэтому может обслуживать гораздо больше пользователей одновременно.
Давайте разберём слово throughput, потому что оно находится прямо в выражении «high-throughput». Throughput означает, сколько работы мы завершаем за заданный промежуток времени. Высокая пропускная способность означает, что мы обслуживаем большое количество токенов и запросов каждую секунду. Именно это vLLM и создан максимизировать.
vLLM решает проблему памяти двумя главными идеями, которые работают вместе:
Не волнуйтесь, мы подробно разберём каждую из них. Начнём с PagedAttention, потому что это сердце vLLM.
- PagedAttention, который управляет KV-кэшем маленькими блоками фиксированного размера вместо одного гигантского блока, так что память не тратится впустую.
- Непрерывный батчинг, который поддерживает занятость GPU, заменяя завершённые запросы новыми на каждом шаге.
PagedAttention, ключевая идея
Давайте пошагово разберём ключевую идею vLLM.
PagedAttention управляет KV-кэшем маленькими блоками фиксированного размера, выделяя память по требованию вместо того, чтобы заранее резервировать один большой блок.
Проще говоря, вместо захвата огромного блока памяти для каждого запроса vLLM выдаёт память маленькими равными кусками — только тогда, когда запросу действительно нужно больше.
Эта идея заимствована из того, как операционная система управляет памятью. Операционная система управляет памятью с помощью маленьких кусочков фиксированного размера, которые называются страницами. Когда программе нужно больше памяти, операционная система выдаёт ей ещё одну страницу. Страницы не обязаны находиться рядом друг с другом в памяти. Операционная система ведёт небольшую таблицу, которая помнит, где находится каждая страница.
vLLM делает ровно то же самое для KV-кэша. Он делит память KV-кэша на маленькие блоки фиксированного размера, где каждый блок хранит заметки для фиксированного числа токенов, например 16 токенов. Когда запросу нужно сохранить больше токенов, vLLM выдаёт ему ещё один блок. Блоки не обязаны находиться рядом друг с другом. vLLM ведёт небольшую таблицу, называемую таблицей блоков, которая помнит, какие блоки принадлежат какому запросу и в каком порядке.
Давайте пройдёмся по примеру.
Шаг 1: приходит запрос и начинает генерировать ответ. vLLM выдаёт ему один блок, достаточный для 16 токенов. Запрос начинает заполнять этот блок.
Небольшая заметка для вас
В какой бы технологической области вы ни работали, познакомьтесь с этими темами:
Мы собрали всё это в одном видео:
- LLM
- RAG
- MCP
- Agent
- Fine-tuning
- Quantization
AI Engineering Explained: LLM, RAG, MCP, Agent, Fine-Tuning, and Quantization
Не нужно прекращать чтение — добавьте в закладки и посмотрите позже, когда будет время. Будущий вы скажет вам спасибо.
Теперь вернёмся к теме.
Шаг 2: ответ превышает 16 токенов. Первый блок заполнен. vLLM просто выдаёт запросу ещё один блок — там, где в памяти есть свободный блок. Он не обязан находиться рядом с первым блоком.
Дальше: ответ продолжает расти, и vLLM продолжает выдавать по одному блоку за раз, только по мере необходимости. Когда ответ завершён, vLLM освобождает все блоки этого запроса сразу, и эти блоки немедленно становятся доступными для других запросов.
Мы можем представить это простой схемой:
PAGED ATTENTION: KV cache split into small fixed-size blocks
GPU memory: [B1][B2][B3][B4][B5][B6][B7][B8][B9] ... (a pool of equal blocks)
Request A's block table -> B1, B4, B7 (3 blocks, given as needed)
Request B's block table -> B2, B3 (2 blocks, given as needed)
Request C's block table -> B5 (1 block, just started)
free blocks ready to hand out: B6, B8, B9Здесь видно, что память — это один общий пул равных по размеру блоков. Каждый запрос получает только те блоки, которые ему действительно нужны, и они могут быть разбросаны где угодно в пуле. Таблица блоков — это маленькая карта, которая связывает запрос с его блоками в правильном порядке. Когда запрос завершается, его блоки сразу возвращаются в свободный пул для следующего запроса.
Проблема решена. Нет чрезмерного резервирования, потому что мы выделяем блок только тогда, когда он действительно нужен. И почти нет фрагментации, потому что каждый блок имеет одинаковый размер, так что любой свободный блок подходит любому запросу. Потери памяти падают почти до нуля.
Как PagedAttention разделяет память
Есть ещё одна красивая вещь, которую даёт PagedAttention, и она появляется почти бесплатно, как только у нас есть блоки. Это совместное использование.
Поскольку KV-кэш теперь состоит из маленьких блоков, два разных запроса могут указывать своими таблицами блоков на один и тот же блок в памяти, вместо того чтобы каждый держал собственную копию. Это значит, что они разделяют память для той части, которая идентична.
Давайте разберём два реальных случая, где такое разделение очень помогает.
Первый случай — идентичные префиксы. Префикс — это начальная часть чего-либо. Представьте, что многие пользователи отправляют запросы, которые все начинаются с одних и тех же длинных системных инструкций, например «You are a polite customer support agent for a car dealership.» Это длинное начало одинаково для всех. Благодаря блокам vLLM может сохранить KV-кэш для этого общего начала один раз и позволить каждому запросу указывать на те же самые блоки. Мы не храним одни и те же заметки много раз. Мы храним их один раз и разделяем. У нас есть подробный блог о prompt caching, который объясняет, как работает переиспользование KV-кэша для идентичного префикса.
Второй случай — beam search. Beam search — это способ генерации текста, при котором модель одновременно исследует несколько возможных ответов, а затем оставляет лучший. Эти несколько ответов, называемые beams, все имеют общее начало и отличаются только позже. Благодаря блокам все beams могут разделять блоки общего начала и использовать отдельные блоки только там, где они действительно расходятся.
Мы можем представить совместное использование так:
SHARING WITH BLOCKS
shared beginning: [B1][B2] <- one copy in memory, used by all
|
+--------------+--------------+
| | |
Request A Request B Beam C
adds [B5] adds [B6] adds [B7]Здесь видно, что блоки B1 и B2 содержат идентичное начало и хранятся только один раз. Три разных пути указывают на эти же два блока для общей части, и каждый добавляет собственные отдельные блоки только для той части, которая уникальна для него. Мы сэкономили память, не сохраняя это начало три раза.
Так PagedAttention не только прекращает потери, но и позволяет запросам разделять память, что помещает ещё больше пользователей на тот же GPU.
Непрерывный батчинг
Теперь давайте изучим вторую большую идею vLLM, которая работает вместе с PagedAttention.
Чтобы понять её, сначала нужно понять батчинг. Батчинг означает запуск множества запросов вместе за один проход, а не по одному. GPU намного эффективнее, когда обрабатывает много запросов вместе, поэтому батчинг — это способ держать GPU занятым и получать высокую пропускную способность.
Но у наивного способа батчинга есть проблема. Давайте посмотрим.
Наивный способ называется статическим батчингом. При статическом батчинге мы собираем batch запросов, запускаем их все вместе и должны ждать, пока каждый запрос в batch завершится, прежде чем начать следующий batch.
Вот в чём подвох. Разные запросы создают ответы очень разной длины. Ответ одного пользователя занимает 20 токенов, а другого — 800 токенов. При статическом батчинге короткий запрос завершается рано и затем просто простаивает, ожидая, пока длинный запрос завершится, потому что весь batch движется вместе. Во время этого ожидания GPU-слот завершённого запроса ничего не делает. Это потраченное впустую время GPU.
И тут на помощь приходит непрерывный батчинг.
Непрерывный батчинг на каждом отдельном шаге удаляет завершённые запросы и добавляет новые ожидающие запросы, вместо того чтобы ждать завершения всего batch.
Проще говоря, как только запрос завершается, vLLM убирает его и сразу берёт новый запрос из очереди ожидания на его место. GPU никогда не простаивает в ожидании.
Помните, decode создаёт ответ по одному токену, поэтому есть много маленьких шагов. На каждом шаге vLLM проверяет: завершился ли какой-нибудь запрос только что? Если да, он удаляет этот запрос и добавляет новый. Batch всегда остаётся заполненным активной работой.
Давайте сравним два подхода на схеме:
STATIC BATCHING (naive): the whole batch waits for the slowest one
step: 1 2 3 4 5 6 7 8
Req A: X X X done - - - - <- idle, wasting the slot
Req B: X X X X X X X done
CONTINUOUS BATCHING (vLLM): finished slots are refilled right away
step: 1 2 3 4 5 6 7 8
slot1: A A A C C C D D <- A finished, C jumped in, then D
slot2: B B B B B B B doneЗдесь видно, что при статическом батчинге запрос A завершился рано, но его слот оставался пустым и простаивал, пока медленный запрос B не завершился. При непрерывном батчинге, как только A завершился, запрос C занял этот слот, а когда C завершился, туда пришёл D. GPU всё время остаётся занятым. Ни один слот не теряется.
Непрерывный батчинг и PagedAttention идеально подходят друг другу. PagedAttention мгновенно освобождает блоки завершённого запроса, а непрерывный батчинг сразу использует эту освобождённую память и освободившийся слот для нового ожидающего запроса. Вместе они поддерживают полную загрузку и памяти GPU, и вычислений GPU.
Так vLLM заставляет GPU работать на полной скорости.
OpenAI-совместимый API-сервер
Теперь посмотрим, как мы на практике используем vLLM.
vLLM предоставляет OpenAI-совместимый API-сервер.
vLLM может работать как сервер, который принимает chat-запросы и возвращает ответы модели.
Это очень важно, потому что огромное количество инструментов и приложений уже написано для работы с API OpenAI. Если vLLM говорит на том же языке, мы можем направить эти существующие инструменты на наш собственный vLLM-сервер, просто изменив адрес, без переписывания кода. Мы можем запускать свою модель на своём GPU, а наше приложение будет общаться с ней так же, как раньше общалось с OpenAI.
Таким образом, vLLM даёт нам высокопроизводительный движок внутри и знакомый, удобный API снаружи. Это та часть, которую важно оценить, потому что она делает vLLM очень простым для внедрения в реальные проекты.
Преимущества vLLM
Давайте быстро соберём преимущества вместе, потому что именно ради них мы используем vLLM.
Проще говоря, vLLM позволяет обслуживать больше пользователей, быстрее и дешевле, на том же GPU, без изменения качества ответов. Модель по-прежнему создаёт те же ответы. vLLM просто перестаёт тратить память и время. У нас есть подробный блог об оптимизации LLM inference, который охватывает более широкий набор техник, стоящих за быстрым обслуживанием.
- Намного более высокая пропускная способность. Поскольку PagedAttention прекращает тратить память впустую, а непрерывный батчинг прекращает тратить время GPU, vLLM может обслуживать намного больше токенов и намного больше пользователей в секунду, чем наивный движок.
- Лучшая утилизация GPU. Утилизация означает, какую часть GPU мы действительно используем. vLLM держит и память GPU, и вычисления GPU близко к полной загрузке, поэтому мы получаем больше пользы от дорогого железа.
- Более низкая стоимость одного запроса. Поскольку один GPU теперь обслуживает намного больше пользователей, стоимость, распределённая на каждого пользователя, сильно падает.
- Легко внедрить. OpenAI-совместимый API означает, что мы можем подключить vLLM к нашим существующим приложениям с очень небольшими изменениями.
В этом красота vLLM.
vLLM в реальном мире
Теперь посмотрим, где vLLM используется в реальных системах.
vLLM — один из самых популярных open-source движков обслуживания, и его широко используют компании, которые хотят запускать открытые большие языковые модели на собственных GPU. Везде, где нужно обслуживать модель для множества пользователей одновременно, vLLM — сильный выбор.
Он особенно мощен в двух типах систем.
Первый — чат-приложения с высоким трафиком. Когда много пользователей общаются одновременно, непрерывный батчинг держит каждый GPU-слот занятым, а PagedAttention упаковывает KV-кэши многих пользователей в одну и ту же память. Так мы обслуживаем большую аудиторию на меньшем числе GPU.
Второй — агентные системы. Агент — это AI-программа, которая работает над задачей шаг за шагом, часто вызывая инструменты и делая много ходов, чтобы завершить работу. Агенты отправляют один и тот же большой блок инструкций на каждом шаге, поэтому разделение идентичных префиксов в PagedAttention экономит много памяти, а непрерывный батчинг поддерживает поток множества коротких шагов без простоя.
Итак, везде, где нужно обслуживать LLM для множества пользователей эффективно и дёшево, vLLM очень помогает.
Вот как работает vLLM. Он обращается с KV-кэшем так, как операционная система обращается с памятью: выдаёт маленькие блоки фиксированного размера по требованию и разделяет их между запросами через PagedAttention; он постоянно держит GPU занятым, меняя запросы местами на каждом шаге с помощью непрерывного батчинга; и оборачивает всё это знакомым OpenAI-совместимым API, так что мы получаем намного более высокую пропускную способность и гораздо лучшую утилизацию GPU на том же железе.
На этом пока всё.
Спасибо
Amit Shekhar Founder @ Outcome School
Читайте все наши качественные блоги здесь.