在Kubernetes上部署分离式LLM推理工作负载
随着大语言模型(LLM)推理工作负载日益复杂,单一的整体服务进程开始触及自身极限。预填充和解码阶段具有根本不同的计算特征,而传统部署方式却将它们强制在同一硬件上运行,导致GPU利用率不足且扩展不灵活。
分离式服务通过将推理流水线划分为不同阶段(如预填充、解码和路由)来解决此问题,每个阶段都作为一个独立的服务运行,可以根据自身需求配置资源和进行扩展。
本文概述了如何在Kubernetes上部署分离式推理,探讨了不同的生态系统解决方案及其在集群上的执行方式,并评估了它们开箱即用的功能。
聚合式推理与分离式推理有何不同?
在深入Kubernetes配置文件之前,了解LLM的两种推理部署模式会有所帮助:在聚合式服务中,单个进程(或紧密耦合的进程组)处理从输入到输出的整个推理生命周期。分离式服务则将流水线拆分为不同阶段,例如预填充、解码和路由,每个阶段都作为独立服务运行(参见下文图1)。
图1. 聚合式与分离式服务对比
聚合式推理
在传统的聚合式设置中,单个模型服务器(或并行配置中的协调服务器组)处理完整的请求生命周期。用户提示输入后,服务器对其进行分词,运行预填充以构建上下文,自回归地生成输出令牌(解码),并返回响应。所有操作都发生在一个进程中或紧密耦合的Pod组内。
这概念上简单,对许多用例都很有效。但这意味着你的硬件需要在两种根本不同的工作负载之间交替:预填充是计算密集型的,受益于高浮点运算性能;而解码受内存带宽限制,受益于大容量、快速的内存。
分离式推理
分离式架构将这些阶段拆分为不同的服务:
- 预填充工作节点处理输入提示。这是计算密集型的。你需要最大化GPU以实现高吞吐量,并可以积极地进行并行化。
- 解码工作节点一次生成一个输出令牌。由于LLM的自回归特性,这受内存带宽限制。你需要具有快速高带宽内存访问能力的GPU。
- 路由器/网关负责分发传入请求,管理预填充和解码阶段之间的键值缓存路由,并处理工作节点间的请求负载均衡。
为何要分离? 主要有三个原因:
- 每个阶段有不同的资源和优化配置: 通过分离,你可以将GPU资源、模型分片技术和批处理大小与每个阶段的需求相匹配,而不是在单一方法上妥协。
- 独立扩展: 预填充和解码的流量模式不同。长上下文提示会产生预填充突发高峰,但解码流稳定。独立扩展每个阶段让你能响应实际需求。
- 更好的GPU利用率: 分离阶段使得每个阶段都能饱和其目标资源(预填充的计算资源,解码的内存带宽),而不是在两者之间交替。
像NVIDIA Dynamo和llm-d这样的框架实现了这种模式。问题变成了:如何在Kubernetes上编排它?
为什么调度是在Kubernetes上实现多Pod推理性能的关键
部署多Pod推理工作负载(无论是模型并行的聚合模型还是分离式模型)只是成功的一半。调度器如何在集群中放置Pod直接影响性能;将张量并行组的Pod放置在同一机架并配备高带宽NVLink互连,可能意味着快速推理与网络瓶颈之间的差别。这里最重要的三种调度能力是:
- 组调度确保一个组中的所有Pod以“全有或全无”的语义被放置,防止部分部署浪费GPU。
- 分层组调度将基本组调度扩展到多级工作负载。在分离式推理中,你需要每个组件或角色的嵌套最低保证:每个张量并行组(例如,组成一个解码实例的四个Pod)必须原子地调度,并且整个系统(至少n个预填充实例 + 至少m个解码实例 + 路由器)也需要系统级协调。没有这一点,一个角色可能消耗所有可用的GPU,而另一个角色无限期等待——这是一种持有资源但无法服务请求的部分部署。
- 拓扑感知放置将紧密耦合的Pod放置在有高带宽互连的节点上,以最小化节点间通信延迟。
这三种能力决定了AI调度器(例如KAI Scheduler)如何根据应用程序的调度约束来放置Pod。此外,AI编排层确定需要组调度的内容以及时机也很重要。例如,当预填充独立扩展时,需要有人决定新Pod形成一个具有最低可用性保证的组,而不干扰现有的解码Pod。因此,编排层和调度器需要在整个应用程序生命周期中紧密合作,处理多级自动扩展、滚动更新等,以确保AI工作负载的最佳运行时条件。
这就是更高级别的工作负载抽象发挥作用的地方。像LeaderWorkerSet (LWS) 和NVIDIA Grove这样的API允许用户声明式地表达其推理应用程序的结构:存在哪些角色,它们如何相互关联,应如何扩展,以及哪些拓扑约束重要。API的算子将该应用程序级意图转化为具体的调度约束(包括PodGroup、组需求、拓扑提示),这些约束决定了创建哪些组以及何时创建。
然后,KAI Scheduler扮演满足这些约束的关键角色,解决如何执行的问题:组调度、分层组调度和拓扑感知放置。在本文中,我们使用KAI作为调度器,尽管社区中还有其他支持这些功能子集的调度器。
部署分离式推理
分离式架构具有多个角色,每个角色都有不同的资源配置和扩展需求。由于分离式流水线中的每个角色都是一个独特的工作负载,使用LWS的一种自然方法是为每个角色创建一个独立的资源。
预填充工作节点(4个副本,2度张量并行):
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: prefill-workers
spec:
replicas: 4
leaderWorkerTemplate:
size: 2
restartPolicy: RecreateGroupOnPodRestart
leaderTemplate:
metadata:
labels:
role: prefill-leader
spec:
containers:
- name: prefill
image: <模型服务器镜像>
args: ["--role=prefill", "--tensor-parallel-size=2"]
resources:
limits:
nvidia.com/gpu: "1"
workerTemplate:
spec:
containers:
- name: prefill
image: <模型服务器镜像>
args: ["--role=prefill"]
resources:
limits:
nvidia.com/gpu: "1"
解码工作节点(2个副本,4度张量并行):
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: decode-workers
spec:
replicas: 2
leaderWorkerTemplate:
size: 4
restartPolicy: RecreateGroupOnPodRestart
leaderTemplate:
metadata:
labels:
role: decode-leader
spec:
containers:
- name: decode
image: <模型服务器镜像>
args: ["--role=decode", "--tensor-parallel-size=4"]
resources:
limits:
nvidia.com/gpu: "1"
workerTemplate:
spec:
containers:
- name: decode
image: <模型服务器镜像>
args: ["--role=decode"]
resources:
limits:
nvidia.com/gpu: "1"
路由器(标准部署——不需要主从拓扑):
apiVersion: apps/v1
kind: Deployment
metadata:
name: router
spec:
replicas: 2
selector:
matchLabels:
app: router
template:
metadata:
labels:
app: router
spec:
containers:
- name: router
image: <路由器镜像>
env:
- name: PREFILL_ENDPOINT
value: "prefill-workers"
- name: DECODE_ENDPOINT
value: "decode-workers"
每个角色都作为自己的资源进行管理。你可以独立地扩展预填充和解码,并按不同的计划更新它们。
需要注意的是,调度器将预填充工作节点和解码工作节点视为独立的工作负载。调度器会成功放置它们,但它不知道它们组成了一个推理流水线。在实践中,这意味着几件事:
- 预填充和解码之间的拓扑协调(将它们放置在同一机架以实现快速的KV缓存传输)需要手动添加Pod亲和性规则,这些规则引用两个LWS资源上的标签。
- 扩展一个角色不会自动考虑另一个角色:如果长上下文请求的突发需要更多的预填充容量,你扩展prefill-workers,但新的预填充Pod不能保证放置到现有解码Pod附近,除非你自己配置了亲和性。
- 推出新模型版本意味着要协调三个独立资源的更新——LWS的分区更新机制支持每个资源的分阶段推出,但跨资源的同步是外部管理的。
最后一点值得强调。推理框架发展迅速,并不总是保证版本间的向后兼容性,因此旧版本的预填充Pod和新版本的解码Pod可能无法通信。模型加载也需要时间,预填充和解码工作节点通常以不同的速率变为就绪状态。在非同步的推出过程中,这可能会造成暂时的失衡,例如许多新的解码Pod已就绪,但很少有新的预填充Pod就绪(反之亦然)。这可能会在你的推理流水线中造成瓶颈,直到一切赶上。
这些模式是可行的。只是协调发生在Kubernetes原语之外:在推理框架的路由层、在自定义自动扩展器、专用算子中,甚至是手动操作。另一种选择是使用Grove的API,它采用不同的方法,将该协调移到Kubernetes资源本身中。
它在一个单一的PodCliqueSet中表达所有角色:
apiVersion: grove.io/v1alpha1
kind: PodCliqueSet
metadata:
name: inference-disaggregated
spec:
replicas: 1
template:
cliqueStartupType: CliqueStartupTypeExplicit
terminationDelay: 30s
cliques:
- name: router
spec:
roleName: router
replicas: 2
podSpec:
schedulerName: kai-scheduler
containers:
- name: router
image: <路由器镜像>
resources:
requests:
cpu: 100m
- name: prefill
spec:
roleName: prefill
replicas: 4
startsAfter: [router]
podSpec:
schedulerName: kai-scheduler
containers:
- name: prefill
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: <模型服务器镜像>
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算子管理每个角色的PodClique,并协调所有角色之间的调度、启动和生命周期。YAML中需要注意的几点:
- 预填充和解码上的
startsAfter: [router]告诉算子,在路由器就绪之前不要启动它们。这是声明式表达的,并通过初始化容器强制执行。当你首次部署时,路由器Pod首先启动并就绪,然后预填充和解码Pod并行启动(因为两者都依赖路由器)。 - 每个clique上的
autoScalingConfig允许你定义每个角色的扩展策略。算子为每个角色创建一个水平Pod自动扩展器,因此预填充和解码根据各自的指标独立扩展。 topologyConstraint与packDomain: rack告诉KAI Scheduler将所有clique打包在同一机架内,通过高带宽互连优化预填充和解码阶段之间的KV缓存传输。
应用此清单后,可以检查Grove创建的所有资源:
$ kubectl get pcs,pclq,pg,pod
NAME AGE
podcliqueset.grove.io/inference-disaggregated 45s
NAME AGE
podclique.grove.io/inference-disaggregated-0-router 44s
podclique.grove.io/inference-disaggregated-0-prefill 44s
podclique.grove.io/inference-disaggregated-0-decode 44s
NAME AGE
podgang.scheduler.grove.io/inference-disaggregated-0 44s
NAME READY STATUS AGE
pod/inference-disaggregated-0-router-k8x2m 1/1 Running 44s
pod/inference-disaggregated-0-router-w9f4n 1/1 Running 44s
pod/inference-disaggregated-0-prefill-abc12 1/1 Running 44s
pod/inference-disaggregated-0-prefill-def34 1/1 Running 44s
pod/inference-disaggregated-0-prefill-ghi56 1/1 Running 44s
pod/inference-disaggregated-0-prefill-jkl78 1/1 Running 44s
pod/inference-disaggregated-0-decode-mn90p 1/1 Running 44s
pod/inference-disaggregated-0-decode-qr12s 1/1 Running 44s
一个PodCliqueSet,三个PodClique(每个角色一个),一个用于协调调度的PodGang,以及与每个角色副本数匹配的Pod。startsAfter依赖通过初始化容器强制执行:预填充和解码Pod等待路由器就绪后,它们的主容器才会启动。
扩展分离式工作负载
一旦分离式工作负载运行起来,扩展就成为核心操作挑战。预填充和解码有不同的瓶颈;团队可能希望根据首令牌时间自动扩展预填充工作节点,并根据令牌间延迟独立自动扩展解码工作节点,以满足服务水平协议,同时最小化GPU成本。
在实践中,分离式扩展在三个层面上运作:
- 单角色扩展:在单个角色内添加或移除Pod(例如,将预填充从4个副本扩展到6个)。
- 单TP组扩展:将完整的张量并行组作为原子单元进行扩展,因为你不能添加半个TP组。
- 跨角色协调:当增加预填充容量时,可能还需要扩展路由器以处理增加的吞吐量,或扩展解码以消耗额外的预填充输出。
不同的工具解决不同层面的问题。
推理框架如何协调扩展
推理框架通过具有推理特定指标可见性的自定义自动扩展器,在应用程序级别解决扩展问题。llm-d的工作负载变体自动扩展器通过Prometheus监控每个Pod的KV缓存利用率和队列深度,使用备用容量模型来确定何时应添加或移除副本。WVA不直接扩展部署,而是将目标副本数作为Prometheus指标发出,由标准HPA/KEDA(基于Kubernetes事件的自动扩展)执行——将扩展触发动作保持在Kubernetes原生原语内。
NVIDIA Dynamo规划器采用了不同的方法:它原生理解分离式服务,分别运行针对TTFT和ITL SLA的预填充和解码扩展循环。它使用时间序列模型预测即将到来的需求,根据分析的每GPU吞吐量曲线计算副本需求,并在两个角色之间强制执行全局GPU预算。
这种全局可见性很重要,因为在实践中,预填充和解码之间存在一个最优比率,该比率随请求模式而变化。将预填充扩展3倍而不扩展解码,额外的输出将无处可去——解码成为瓶颈,KV缓存传输排队。应用程序级别的自动扩展器可以处理这个问题,因为它们可以看到整个流水线;而针对单个资源的Kubernetes原生HPA本质上不能维护跨资源比率。
使用独立的LWS资源进行扩展
每个角色一个LWS,你可以独立扩展每个角色:
kubectl scale lws prefill-workers --replicas=6
kubectl scale lws decode-workers --replicas=3
标准的HPA可以分别针对每个LWS,或者外部自动扩展器(如Dynamo规划器或llm-d的自动扩展器)做出协调决策并更新两者。协调逻辑存在于自动扩展器中,而不是Kubernetes资源本身。
使用Grove进行扩展
Grove将单角色扩展引入到一个单一资源中。每个PodClique都有自己的副本数和可选的autoScalingConfig,因此HPA可以根据每个角色的指标独立管理角色:
kubectl scale pclq inference-disaggregated-0-prefill --replicas=6
算子会创建额外的预填充Pod,同时保持路由器和解码不变:
NAME AGE
podclique.grove.io/inference-disaggregated-0-router 5m
podclique.grove.io/inference-disaggregated-0-prefill 5m
podclique.grove.io/inference-disaggregated-0-decode 5m
NAME READY STATUS AGE
pod/inference-disaggregated-0-router-k8x2m 1/1 Running 5m
pod/inference-disaggregated-0-router-w9f4n 1/1 Running 5m
pod/inference-disaggregated-0-prefill-abc12 1/1 Running 5m
pod/inference-disaggregated-0-prefill-def34 1/1 Running 5m
pod/inference-disaggregated-0-prefill-ghi56 1/1 Running 5m
pod/inference-disaggregated-0-prefill-jkl78 1/1 Running 5m
pod/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 5m
pod/inference-disaggregated-0-decode-qr12s 1/1 Running 5m
六个预填充Pod,两个路由器Pod,两个解码Pod——只有预填充发生了变化。
对于内部使用多节点张量并行的角色,PodCliqueScalingGroup确保多个PodClique作为一个单元一起扩展,同时保持它们之间的副本比率。例如,在每个预填充实例由一个领导者Pod和四个工作Pod组成的配置中:
podCliqueScalingGroups:
- name: prefill
cliqueNames: [pleader, pworker]
replicas: 2
minAvailable: 1
scaleConfig:
maxReplicas: 4
使用replicas: 2,这将创建两个完整的预填充实例:2 x(一个领导者 + 四个工作者)= 总共10个Pod。minAvailable: 1保证意味着系统不会缩减到一个完整的张量并行组以下。
将组从两个副本扩展到三个副本,添加第三个完整实例,同时保持1:4的领导者与工作者比率:
$ kubectl scale pcsg inference-disaggregated-0-prefill --replicas=3
领导者和工作者clique作为一个单元一起扩展,新副本(prefill-2)有一个pleader Pod和四个pworker Pod,匹配该比率。为第三个副本创建了一个新的PodGang,以确保它被组调度。
NAME AGE
podcliquescalinggroup.grove.io/inference-disaggregated-0-prefill 10m
NAME AGE
podclique.grove.io/inference-disaggregated-0-prefill-0-pleader 10m
podclique.grove.io/inference-disaggregated-0-prefill-0-pworker 10m
podclique.grove.io/inference-disaggregated-0-prefill-1-pleader 10m
podclique.grove.io/inference-disaggregated-0-prefill-1-pworker 10m
podclique.grove.io/inference-disaggregated-0-prefill-2-pleader 8s # 新增
podclique.grove.io/inference-disaggregated-0-prefill-2-pworker 8s # 新增
NAME AGE
podgang.scheduler.grove.io/inference-disaggregated-0 10m
podgang.scheduler.grove.io/inference-disaggregated-0-prefill-0 10m
podgang.scheduler.grove.io/inference-disaggregated-0-prefill-1 8s # 新增
入门指南
无论你是在运行单个分离式流水线还是在集群中操作数十个,这些构建块正在出现,并且社区正在公开地构建它们。本博客中的每种方法都代表了简单性和集成协调之间光谱上的不同点。
正确的选择取决于你的工作负载、团队的操作模式,以及你希望平台处理多少生命周期管理,而不是应用程序层。
更多信息请查看以下资源。
- NVIDIA Grove
- KAI Scheduler
- NVIDIA DynamoFINISHED