Дезагрегированный инференс LLM в Kubernetes: префилл, декодирование и планирование подов

от автора

С ростом сложности рабочих нагрузок инференса больших языковых моделей (LLM) единый монолитный процесс обслуживания упирается в свои пределы. У префилла и декодирования принципиально разные профили вычислений, но традиционные развёртывания заставляют их работать на одном оборудовании. В итоге GPU недозагружены, а масштабирование — негибкое.

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

Команда VK Cloud перевела статью, в которой разбирается, как развернуть дезагрегированный инференс в Kubernetes. Здесь мы посмотрим на разные решения экосистемы, как они работают в кластере и что дают «из коробки».

Агрегированный vs дезагрегированный инференс

Прежде чем погружаться в манифесты Kubernetes, разберём два режима развёртывания инференса для LLM. При агрегированном инференсе единый процесс (или тесно связанная группа процессов) обрабатывает весь жизненный цикл инференса от входа к выходу. Дезагрегированный инференс разбивает конвейер на отдельные этапы — префилл, декодирование и маршрутизацию. Каждый этап работает как независимый сервис.

Агрегированный инференс: всё в одном процессе

В классической конфигурации один сервер модели (или согласованная группа серверов в параллельном режиме) берёт на себя весь жизненный цикл запроса. Входит промт пользователя, тот же сервер токенизирует его, выполняет префилл для построения контекста, потом авторегрессионно генерирует выходные токены и отдаёт ответ. Всё это происходит внутри одного процесса или тесно связанного набора подов.

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

Дезагрегированный инференс: каждый этап — отдельный сервис

В дезагрегированных архитектурах эти этапы разводят по разным сервисам.

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

  • Воркеры декодирования отвечают за поэтапную генерацию выходных токенов. Из-за авторегрессионной природы LLM этот этап ограничен пропускной способностью памяти, и на первый план выходят GPU с быстрым доступом к памяти большого объёма.

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

Всё это разводят по компонентам по трём причинам.

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

Во‑вторых, этапы ведут себя по‑разному по отношению к трафику. Длинный промт даёт резкий всплеск на префилле, но приводит к относительно ровному потоку декодирования. Возможность масштабировать каждый этап независимо позволяет подстраиваться под реальную картину запросов.

В‑третьих, повышается общая загрузка GPU. Когда этапы разделены, каждый из них целенаправленно насыщает «свой» лимит — вычислительные блоки на префилле и пропускную способность памяти на декоде — вместо того, чтобы по очереди ограничиваться то одним, то другим.

Такой паттерн реализуют, например, NVIDIA Dynamo и llm‑d. Дальше встаёт практический вопрос: как именно оркестрировать подобную архитектуру в Kubernetes?

Почему планировщик решает всё

Развёртывание многоподовой нагрузки инференса — будь то модель‑параллельная агрегированная схема или дезагрегированный вариант — решает только половину задачи. Не меньшее значение имеет то, как планировщик раскладывает поды по кластеру: от этого напрямую зависит производительность. Если поды одной группы тензорного параллелизма оказываются на узлах без быстрых связей, инференс упирается в сеть; если удаётся посадить их на одну стойку с NVLink, то межузловое взаимодействие почти не мешает скорости.

Здесь особенно важны три возможности планирования. 

  • Gang‑планирование. Оно размещает все поды группы по принципу «всё или ничего», чтобы не возникало частичных развертываний, которые держат занятыми GPU, но не могут обслуживать запросы. 

  • Иерархическое gang‑планирование для многоуровневых нагрузок. В дезагрегированном инференсе нужна гарантия минимального набора подов для каждой роли: каждая группа тензорного параллелизма (например, четыре пода, которые вместе образуют один инстанс декодирования) должна запускаться атомарно, а вся система — как минимум n инстансов префилла, как минимум m инстансов декодирования и маршрутизатор — должна координироваться на уровне всей нагрузки. Без этого одна роль может занять все доступные GPU, а другая так и не получит ресурсов: частично развернутая система съедает квоту, но работать не может. 

  • Размещение с учётом топологии. Тесно связанные поды нужно колоцировать на узлах с высокоскоростными соединениями, чтобы минимизировать задержки обмена.

Эти механизмы определяют, как специализированный планировщик для AI‑нагрузок, вроде KAI Scheduler, размещает поды исходя из ограничений, которые задаёт приложение. Слой оркестрации при этом должен не только сформулировать, что именно нужно запускать gang‑подходом, но и вовремя пересматривать это решение. Если, например, префилл масштабируется отдельно, кто‑то должен определить, что новые поды образуют ещё одну «связку» с гарантированным минимумом доступности и при этом не ломают уже работающие поды декодирования. Поэтому оркестратор и планировщик работают в связке на всём жизненном цикле приложения: обрабатывают многоуровневое автомасштабирование, выкаты, переносы подов и следят за тем, чтобы условия исполнения для AI‑нагрузок оставались оптимальными.

На этом месте становятся актуальными более высокоуровневые абстракции рабочих нагрузок. API вроде LeaderWorkerSet (LWS) и NVIDIA Grove позволяют декларативно описать структуру инференс‑приложения: какие роли в нём есть, как они связаны, как должны масштабироваться и какие топологические ограничения важны. Оператор, реализующий такой API, переводит намерения на уровне приложения в набор конкретных ограничений для планировщика — PodGroup, требования к gang‑планированию, подсказки по топологии, — определяя, какие «ганги» создавать и в какой момент.

Дальше вступает в работу KAI Scheduler, который отвечает за «как»: применяет gang‑планирование, его иерархические варианты и учитывает топологию кластера при размещении подов. В примерах этой статьи используется именно KAI, хотя в сообществе есть и другие планировщики с частью таких возможностей. Более широкий ландшафт решений для планирования можно посмотреть в экосистеме Cloud Native Computing Foundation (CNCF).

Разворачиваем дезагрегированный инференс

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

Префилл-воркеры (четыре реплики, тензорный параллелизм степени 2):

apiVersion: leaderworkerset.x-k8s.io/v1kind: LeaderWorkerSetmetadata:  name: prefill-workersspec:  replicas: 4  leaderWorkerTemplate:    size: 2    restartPolicy: RecreateGroupOnPodRestart    leaderTemplate:      metadata:        labels:          role: prefill-leader      spec:        containers:        - name: prefill          image: <model-server-image>          args: ["--role=prefill", "--tensor-parallel-size=2"]          resources:            limits:              nvidia.com/gpu: "1"    workerTemplate:      spec:        containers:        - name: prefill          image: <model-server-image>          args: ["--role=prefill"]          resources:            limits:              nvidia.com/gpu: "1"

Воркеры декодирования (две реплики, тензорный параллелизм степени 4):

apiVersion: leaderworkerset.x-k8s.io/v1kind: LeaderWorkerSetmetadata:  name: decode-workersspec:  replicas: 2  leaderWorkerTemplate:    size: 4    restartPolicy: RecreateGroupOnPodRestart    leaderTemplate:      metadata:        labels:          role: decode-leader      spec:        containers:        - name: decode          image: <model-server-image>          args: ["--role=decode", "--tensor-parallel-size=4"]          resources:            limits:              nvidia.com/gpu: "1"    workerTemplate:      spec:        containers:        - name: decode          image: <model-server-image>          args: ["--role=decode"]          resources:            limits:              nvidia.com/gpu: "1"

Маршрутизатор (обычный Deployment, без leader-worker):

apiVersion: apps/v1kind: Deploymentmetadata:  name: routerspec:  replicas: 2  selector:    matchLabels:      app: router  template:    metadata:      labels:        app: router    spec:      containers:      - name: router        image: <router-image>        env:        - name: PREFILL_ENDPOINT          value: "prefill-workers"        - name: DECODE_ENDPOINT          value: "decode-workers"

Каждая роль управляется как собственный ресурс. Можно масштабировать префилл и декодирование независимо и обновлять их по разным расписаниям.

Где у LWS-подхода ограничения

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

  • Координация топологии между префиллом и декодированием (размещение их на одной стойке для быстрой передачи KV-кеша) требует ручного добавления правил pod affinity, которые ссылаются на метки между двумя LWS-ресурсами.

  • Масштабирование одной роли автоматически не учитывает другую: если всплеск длинноконтекстных запросов требует больше ёмкости префилла, вы масштабируете prefill-workers, но новые префилл-поды не гарантированно окажутся рядом с существующими подами декодирования, если вы сами не настроили affinity.

  • Выкатка новой версии модели означает координацию обновлений между тремя независимыми ресурсами — механизм частичных обновлений LWS поддерживает поэтапные выкатки на уровне ресурса, но синхронизация между ресурсами управляется извне.

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

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

Альтернатива: PodCliqueSet от Grove

Другой вариант — использовать API Grove, который применяет иной подход и переносит координацию в сам ресурс Kubernetes. Он выражает все роли в одном PodCliqueSet:

apiVersion: grove.io/v1alpha1kind: PodCliqueSetmetadata:  name: inference-disaggregatedspec:  replicas: 1  template:    cliqueStartupType: CliqueStartupTypeExplicit    terminationDelay: 30s    cliques:    - name: router      spec:        roleName: router        replicas: 2        podSpec:          schedulerName: kai-scheduler          containers:          - name: router            image: <router-image>            resources:              requests:                cpu: 100m    - name: prefill      spec:        roleName: prefill        replicas: 4        startsAfter: [router]        podSpec:          schedulerName: kai-scheduler          containers:          - name: prefill            image: <model-server-image>            args: ["--role=prefill", "--tensor-parallel-size=2"]            resources:              limits:                nvidia.com/gpu: "1"        autoScalingConfig:          maxReplicas: 8          metrics:          - type: Resource            resource:              name: cpu              target:                type: Utilization                averageUtilization: 70    - name: decode      spec:        roleName: decode        replicas: 2        startsAfter: [router]        podSpec:          schedulerName: kai-scheduler          containers:          - name: decode            image: <model-server-image>            args: ["--role=decode", "--tensor-parallel-size=4"]            resources:              limits:                nvidia.com/gpu: "1"        autoScalingConfig:          maxReplicas: 6          metrics:          - type: Resource            resource:              name: cpu              target:                type: Utilization                averageUtilization: 80    topologyConstraint:      packDomain: rack

Оператор Grove управляет PodCliques для каждой роли и координирует планирование, запуск и жизненный цикл для всех них. Несколько моментов, которые стоит отметить в YAML:

  • startsAfter: [router] у префилла и декодирования говорит оператору отложить их запуск до готовности маршрутизатора. Это выражено декларативно и обеспечивается через init-контейнеры. При первом развёртывании поды маршрутизатора стартуют и становятся готовыми первыми, затем префилл-поды и поды декодирования стартуют параллельно (поскольку оба зависят от маршрутизатора).

  • autoScalingConfig на каждой clique позволяет определить политики масштабирования по ролям. Оператор создаёт horizontal pod autoscaler (HPA) для каждой, поэтому префилл и декодирование масштабируются независимо на основе собственных метрик.

  • topologyConstraint с packDomain: rack говорит KAI Scheduler упаковывать все cliques в пределах одной стойки, оптимизируя передачу KV-кеша между этапами префилла и декодирования через высокоскоростные соединения.

После применения этого манифеста можно осмотреть все ресурсы, которые создаёт Grove:

$ kubectl get pcs,pclq,pg,podNAME                                            AGEpodcliqueset.grove.io/inference-disaggregated   45sNAME                                                  AGEpodclique.grove.io/inference-disaggregated-0-router   44spodclique.grove.io/inference-disaggregated-0-prefill  44spodclique.grove.io/inference-disaggregated-0-decode   44sNAME                                                AGEpodgang.scheduler.grove.io/inference-disaggregated-0  44sNAME                                              READY   STATUS    AGEpod/inference-disaggregated-0-router-k8x2m        1/1     Running   44spod/inference-disaggregated-0-router-w9f4n        1/1     Running   44spod/inference-disaggregated-0-prefill-abc12       1/1     Running   44spod/inference-disaggregated-0-prefill-def34       1/1     Running   44spod/inference-disaggregated-0-prefill-ghi56       1/1     Running   44spod/inference-disaggregated-0-prefill-jkl78       1/1     Running   44spod/inference-disaggregated-0-decode-mn90p        1/1     Running   44spod/inference-disaggregated-0-decode-qr12s        1/1     Running   44s

Один PodCliqueSet, три PodCliques (по одной на роль), один PodGang для координированного планирования и поды, соответствующие количеству реплик каждой роли. Зависимость startsAfter обеспечивается через init-контейнеры: префилл-поды и поды декодирования ждут, пока маршрутизатор не станет готов, прежде чем их основные контейнеры стартуют.

Масштабирование дезагрегированных нагрузок

Как только дезагрегированная нагрузка поднята, главный операционный вопрос — как её масштабировать. У префилла и декодирования разные узкие места: команды обычно хотят автоматически увеличивать число префилл‑воркеров по метрике «время до первого токена» (TTFT), а воркеров декодирования — по задержке между токенами (ITL). Цель — укладываться в SLA и при этом не переплачивать за GPU.

На практике масштабирование в такой архитектуре идёт сразу на трёх уровнях.

  • Внутри роли: добавляем или убираем поды конкретной роли, например увеличиваем количество префилл‑реплик с четырёх до шести. 

  • Группы тензорного параллелизма: масштабирутся не отдельные поды, а полные TP‑группы, потому что «половину» такой группы добавить нельзя. 

  • Координация между ролями: если мы добавили ёмкость префилла, может понадобиться одновременно дать больше ресурсов маршрутизатору и расширить декод, чтобы он успевал перерабатывать дополнительные результаты префилла.

Разные инструменты закрывают разные уровни этой задачи.

Как фреймворки инференса координируют масштабирование

Фреймворки инференса решают задачу на уровне приложения через свои автоскейлеры, у которых есть доступ к специфичным для инференса метрикам. Например, автоскейлер workload variant autoscaler (WVA) в llm‑d смотрит на использование KV‑кэша и длину очереди по каждому поду через Prometheus и по модели свободной ёмкости решает, когда стоит добавлять или убирать реплики. При этом он не меняет Deployment напрямую: вместо этого публикует желаемое количество реплик как метрику Prometheus, а дальше уже стандартный HPA и KEDA приводят реальное состояние к целевому на основе привычных Kubernetes‑примитивов.

Планировщик NVIDIA Dynamo идёт другим путём: он изначально понимает дезагрегированный инференс и запускает отдельные контуры масштабирования для префилла и декодирования, нацеленные соответственно на соблюдение SLA по TTFT и ITL. Ожидаемый спрос он предсказывает по моделям временных рядов, требования к числу реплик выводит из заранее спрофилированных кривых пропускной способности по каждой конфигурации GPU и следит за тем, чтобы обе роли укладывались в общий бюджет по GPU.

Глобальный взгляд важен из‑за оптимального соотношения мощностей между префиллом и декодом, которое меняется вместе с характером запросов. Если механически увеличить префилл в три раза и не трогать декодирование, новые результаты просто упрутся в узкое место: декод станет тормозом, очереди передачи KV‑кэша начнут расти. Автоскейлеры уровня приложения справляются с этим, потому что видят весь конвейер целиком, тогда как Kubernetes‑нативный HPA умеет работать только с отдельно взятыми ресурсами и не знает о нужных соотношениях между ними.

Масштабирование при раздельных ресурсах LeaderWorkerSet

Если для каждой роли описан свой LeaderWorkerSet, роли масштабируются независимо. Можно, например, увеличить только префилл:

kubectl scale lws prefill-workers --replicas=6kubectl scale lws decode-workers --replicas=3

Стандартный HPA может нацеливаться на каждый LWS по отдельности, а внешний автоскейлер (тот же планировщик Dynamo или автоскейлер llm‑d) — принимать согласованные решения и обновлять сразу оба ресурса. Логика координации здесь целиком лежит в автоскейлере, а сами Kubernetes‑ресурсы остаются «тонкими» обёртками.

Масштабирование с помощью Grove

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

kubectl scale pclq inference-disaggregated-0-prefill --replicas=6

Оператор создаёт дополнительные префилл‑поды, остальная конфигурация остаётся прежней:

NAME                                              AGEpodclique.grove.io/inference-disaggregated-0-router   5mpodclique.grove.io/inference-disaggregated-0-prefill  5mpodclique.grove.io/inference-disaggregated-0-decode   5mNAME                                              READY   STATUS    AGEpod/inference-disaggregated-0-router-k8x2m        1/1     Running   5mpod/inference-disaggregated-0-router-w9f4n        1/1     Running   5mpod/inference-disaggregated-0-prefill-abc12       1/1     Running   5mpod/inference-disaggregated-0-prefill-def34       1/1     Running   5mpod/inference-disaggregated-0-prefill-ghi56       1/1     Running   5mpod/inference-disaggregated-0-prefill-jkl78       1/1     Running   5mpod/inference-disaggregated-0-prefill-tu34v       1/1     Running   12s  # новыйpod/inference-disaggregated-0-prefill-wx56y       1/1     Running   12s  # новыйpod/inference-disaggregated-0-decode-mn90p        1/1     Running   5mpod/inference-disaggregated-0-decode-qr12s        1/1     Running   5m

В итоге имеем шесть префилл‑подов, два пода маршрутизатора и два пода декодирования; изменился только префилл.

Масштабирование TP‑групп через PodCliqueScalingGroup

Когда внутри роли используется многоузловой тензорный параллелизм, PodCliqueScalingGroup позволяет масштабировать несколько PodClique как единое целое и держать нужное соотношение реплик между ними. Допустим, каждый инстанс префилла состоит из одного лидера и четырёх воркеров:

podCliqueScalingGroups:  - name: prefill    cliqueNames: [pleader, pworker]    replicas: 2    minAvailable: 1    scaleConfig:      maxReplicas: 4

При replicas: 2 мы получаем два полных инстанса префилла: 2 × (1 лидер + 4 воркера) = 10 подов. Поле minAvailable: 1 не даёт системе опуститься ниже одной полной TP‑группы при масштабировании вниз.

Если увеличить число реплик с двух до трёх:

kubectl scale pcsg inference-disaggregated-0-prefill --replicas=3

оператор добавит третий полный инстанс, соблюдая соотношение 1:4 между лидером и воркерами; для новой группы создаётся отдельный PodGang, чтобы она целиком планировалась по принципу gang‑планирования:

NAME                                                          AGEpodcliquescalinggroup.grove.io/inference-disaggregated-0-prefill  10mNAME                                                          AGEpodclique.grove.io/inference-disaggregated-0-prefill-0-pleader    10mpodclique.grove.io/inference-disaggregated-0-prefill-0-pworker    10mpodclique.grove.io/inference-disaggregated-0-prefill-1-pleader    10mpodclique.grove.io/inference-disaggregated-0-prefill-1-pworker    10mpodclique.grove.io/inference-disaggregated-0-prefill-2-pleader    8s   # новыйpodclique.grove.io/inference-disaggregated-0-prefill-2-pworker    8s   # новыйNAME                                                          AGEpodgang.scheduler.grove.io/inference-disaggregated-0              10mpodgang.scheduler.grove.io/inference-disaggregated-0-prefill-0    10mpodgang.scheduler.grove.io/inference-disaggregated-0-prefill-1    8s   # новый

С чего начинать и как выбирать подход

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

Выбор зависит от самой нагрузки, вашей операционной модели и того, какую часть управления жизненным циклом вы хотите отдать платформе, а какую — оставить на уровне приложений и автоскейлеров. Для монолитного сервера LLM‑инференса узким местом быстро становятся различия между префиллом и декодированием: первый нагружает вычисления, второй — память. Дезагрегированный подход разбивает конвейер на отдельные сервисы и позволяет масштабировать их по отдельности. В материале в целом разбирается, как разворачивать такую схему в Kubernetes с помощью LeaderWorkerSet и NVIDIA Grove, как помогает в этом KAI Scheduler и как организовать масштабирование по ролям — полезно платформенным командам и ML‑инженерам, собирающим инференс‑стек на Kubernetes.

ссылка на оригинал статьи https://habr.com/ru/articles/1040076/