Grove:LLM推理系统的Kubernetes表达层

2026-06-07
14 min read

背景:为什么Pod视角不够

Kubernetes原生模型可以简单理解成两层:

  • Deployment、StatefulSet这类workload资源,负责定义“这些Pod使用什么模板创建、如何更新、如何保活”;
  • 默认调度器负责在Pod创建出来之后,给每个Pod找一台合适的机器运行。

这个模型对简单Web服务很顺手。普通Web服务通常是无状态的,一个Pod代表一份完整服务能力:多一个Pod就多一份容量,少一个Pod就少一份容量,扩缩容基本等于调整副本数。

但LLM推理服务不是这么简单。一个完整的推理服务往往由一组角色不同、相互依赖的组件共同提供能力:

  • frontend或router负责入口流量和请求分发,不一定和推理执行层绑定在一起;
  • prefill worker负责处理输入prompt阶段,通常会一次性处理较长上下文,并生成后续生成阶段要用的KV cache;这一阶段更偏计算密集;
  • decode worker负责拿到KV cache之后执行输出生成,按token逐步生成结果;这一阶段每一步计算规模相对小一些,更偏访存密集;
  • leader/worker之间可能存在启动顺序、服务发现和组件就绪依赖;
  • 如果模型实例跨多节点部署,还会受到NVLink、机架、网络域等拓扑关系影响。

这时如果还只按Pod调度,就会遇到几个实际问题。

1. 部分Pod成功调度没有实际业务意义

普通在线服务里,一个Pod调度成功通常就代表多了一份容量。LLM推理里不是这样。prefill/decode分离场景下,prefill起了、decode没起,这条链路还是不可用;leader-worker架构里,worker Pod已经调度成功,但leader/head Pod没有调度成功或没有ready,这组worker也组不成一个完整模型实例。

更麻烦的是资源已经被占住了。部分Pod已经启动并占用GPU,但不能对外提供完整推理能力。对业务来说它不可用,对集群来说它又不是空闲资源。

2. 扩缩容不是一个维度

普通服务扩缩容,通常就是副本数从10个变成20个。但prefill和decode的扩缩容逻辑不一样。如果把prefill、decode拆成多个Deployment分别扩副本,表面上简单,实际很容易扩出一组不匹配的组件。

比如prefill扩上去了,decode没跟上,请求还是会卡在生成阶段;decode扩上去了,但prefill不够,TTFT(首token延迟)还是下不来。这里需要表达的不只是“每个组件要多少副本”,还包括“这些组件之间应该按什么关系扩缩”。

3. 拓扑约束不能只靠Pod的节点亲和性

LLM推理不是只要有GPU就行。prefill和decode之间可能要传KV cache,多节点模型实例之间存在跨节点通信,同一个服务实例内部的组件如果跨机架、跨网络拓扑域,延迟和稳定性都会受影响。

如果只给单个Pod写nodeAffinity,调度器看到的仍然是一个个独立Pod。它不知道这些Pod属于同一个推理服务实例,也不知道哪些组件应该尽量靠近,哪些服务副本又应该打散。

更合理的方式是:同一个服务实例内部尽量内聚,减少通信成本;不同服务实例之间适当打散,控制故障半径,避免单个拓扑域故障打穿整个服务容量。

问题拆分:表达和调度

所以LLM推理调度问题可以先拆成两层:

  • 第一层是表达问题:这个推理系统由哪些组件组成,这些组件之间是什么关系,怎样才算一组可用能力;
  • 第二层是调度问题:这组能力应该放到哪些GPU、哪些节点、哪个拓扑域里,资源不够时又该怎么取舍。

这篇先看第一层。Grove要做的事,就是把调度器需要理解的workload关系表达出来。

Grove的定位

Grove是NVIDIA开源的Kubernetes API,用一个声明式接口编排AI推理工作负载。官方README里的定位很直接:从简单的单Pod部署,到复杂的多节点、分离式推理(disaggregated inference)系统,都可以用一个Custom Resource描述。Grove会围绕这个spec创建和维护相关对象,并把分层gang scheduling、拓扑感知放置、多层级自动扩缩容和显式启动顺序这些要求写成Kubernetes能处理的结构。

Grove的角色是“推理服务级别的CRD和Operator”。Deployment或StatefulSet更适合表达一组同构Pod:这些Pod长得差不多,用同一个模板创建,副本数可以一起增减。但LLM推理系统里的组件不是同构的。prefill、decode、router、leader、worker的角色不同,资源特征不同,扩缩容控制流程也不同。Grove把这些组件放到一个更上层的对象里统一描述。

它的价值不在于发明一个新的推理框架,而是把过去散落在多个Deployment、StatefulSet、Service、Helm values、脚本和外部控制器里的约定,变成一个显式的workload声明。

Grove的核心对象

1. 四个对象

Grove里最关键的对象有四个:

对象作用
PodClique表示同一类角色的一组Pod,例如prefill worker、decode worker、leader、frontend。每个clique有独立的配置、replica和伸缩逻辑。
PodCliqueScalingGroup表示需要一起扩缩、一起调度的一组PodClique,例如prefill leader和它对应的prefill workers。
PodCliqueSetGrove的顶层workload对象,用来描述整个多组件推理系统,包括组件、扩缩容策略、启动顺序、拓扑约束和gang调度约束。
PodGang面向调度器的协议对象,用来把Grove定义的workload转成gang scheduling约束。Grove创建它,但真正的放置决策仍由兼容的scheduler完成。

Grove PodCliqueSet、PodCliqueScalingGroup、PodClique与PodGang的关系

2. 从PodCliqueSet到Pod

从实现上看,PodCliqueSet控制器不会直接把所有Pod都创建出来。它会先维护PodCliquePodCliqueScalingGroupPodGang,以及Service、ServiceAccount、HPA、ResourceClaim等配套对象。PodCliqueScalingGroup控制器继续管理它下面的PodCliquePodClique控制器最终创建实际Pod。

不要把这个理解成一个严格的“step 1/2/3”脚本。这里描述的是Kubernetes controller的日常工作方式:目标状态写在PodCliqueSet里,控制器持续创建、更新、删除相关对象,让实际状态和目标状态保持一致。

这就补上了Kubernetes原生对象里缺失的一层。

3. Grove补上的关系

默认调度器看到的是一批Pod:

  • 这是一个prefill Pod;
  • 这是一个decode Pod;
  • 这是一个leader Pod;
  • 这是一个worker Pod。

Grove想表达的是这些Pod背后的关系:

  • 这些Pod属于同一个推理服务;
  • 哪些组件组成一个最小可运行单元;
  • 哪些组件必须一起调度;
  • 哪些组件可以独立扩容;
  • 哪些组件应该尽量靠近;
  • 哪些副本之间应该打散。

这就是workload级表达和Pod级表达的区别。

Grove的几个关键能力

1. 用一个workload描述完整推理服务

没有Grove的时候,一个disaggregated inference服务可能会拆成多个Deployment或StatefulSet:prefill一个,decode一个,router一个,leader/worker又是一组。每个对象都可以独立运行,但它们之间的关系并不在Kubernetes原生对象里表达。

很多约束只能靠业务约定、脚本、Helm模板或者外部控制器维护:

  • prefill和decode的比例应该是多少;
  • 哪些worker属于同一个leader;
  • 哪些组件必须一起扩容;
  • 哪些组件启动顺序不能乱;
  • 哪些组件应该在拓扑上靠近;
  • 哪些服务副本应该相互打散。

这些关系如果不在workload上定义,调度器也很难理解。Grove的第一层价值,就是把这些散落的关系写进PodCliqueSet

2. 表达分层gang scheduling

基础的gang scheduling通常是一个job里的所有Pod要么一起成功,要么一起pending。这个模型对训练任务比较直观,因为训练任务少一个worker可能就跑不起来。

推理服务不完全一样。它既需要最小可运行组合,又需要不同组件按不同粒度扩缩。

比如:

  • 整个服务至少需要一组prefill和一组decode;
  • leader和它下面的workers需要一起调度;
  • decode worker可以根据输出压力单独增加;
  • prefill worker可以根据输入上下文压力单独增加;
  • 服务可以先满足最小可用副本,再机会式扩容更多副本。

如果把这些全部压成一个大的PodGroup,约束会太粗;如果全部拆成无关Pod,又会丢掉整体原子性。Grove的PodCliquePodCliqueScalingGroupPodCliqueSet就是在这两者中间补一层结构。

下面这个YAML是一个简化示例。重点不在镜像,而在三个层次:router作为独立clique,prefilldecode组成一个serving-cellserving-cell可以整体复制和扩缩。

apiVersion: grove.io/v1alpha1
kind: PodCliqueSet
metadata:
  name: inference
spec:
  replicas: 1 # PodCliqueSet整体副本数
  template:
    cliques:
      - name: router # 独立入口组件
        spec:
          roleName: router
          replicas: 1
          podSpec:
            containers:
              - name: router
                image: router:v1

      - name: prefill # 每个serving-cell里1个prefill
        spec:
          roleName: prefill
          replicas: 1
          podSpec:
            containers:
              - name: prefill
                image: prefill:v1
                resources:
                  requests:
                    cpu: "2"
                    nvidia.com/gpu: 2
                  limits:
                    cpu: "2"
                    nvidia.com/gpu: 2

      - name: decode # 每个serving-cell里2个decode
        spec:
          roleName: decode
          replicas: 2
          podSpec:
            containers:
              - name: decode
                image: decode:v1
                resources:
                  requests:
                    cpu: "2"
                    nvidia.com/gpu: 1
                  limits:
                    cpu: "2"
                    nvidia.com/gpu: 1

    podCliqueScalingGroups: # 把prefill和decode组成一个可整体扩缩的组
      - name: serving-cell
        cliqueNames:
          - prefill
          - decode
        replicas: 2 # 两组serving-cell
        minAvailable: 1 # 至少1组要成组调度成功
        scaleConfig: # 自动扩缩容配置
          minReplicas: 1
          maxReplicas: 4
          metrics:
            - type: Resource
              resource:
                name: cpu
                target:
                  type: Utilization
                  averageUtilization: 80

这里有一个细节容易误解:PodCliqueScalingGroupConfig.replicas会和成员PodClique.spec.replicas相乘。上面serving-cell.replicas: 2prefill.replicas: 1decode.replicas: 2,含义就是两组serving cell,每组里有1个prefill和2个decode。minAvailable: 1则表示至少1组scaling group replica要作为gang被保证调度。

这类结构用几个独立Deployment也能拼出来,但“哪些组件是一组”“最小可运行单元是什么”就不在Kubernetes对象里了。

3. 把拓扑意图放进workload定义

拓扑这里的重点不是给某个Pod加nodeSelector或nodeAffinity,而是表达服务实例内部和服务实例之间的不同拓扑目标:

  • 同一个服务实例内部,prefill、decode、worker group尽量靠近,减少通信和KV cache传输成本;
  • 不同服务实例之间适当打散,避免同一个rack、同一个network block故障影响全部副本;
  • 对强通信组件使用更强的拓扑约束;
  • 对普通入口组件放宽约束,甚至从核心GPU调度单元里剥离出去。

这个思路和普通“亲和/反亲和”不完全一样。亲和/反亲和还是Pod维度的规则;Grove想表达的是服务维度的拓扑倾向。

Grove当前API里,拓扑层级通过ClusterTopologyBinding定义。它的作用很直接:先把集群里有哪些拓扑层级说清楚,例如zone、block、rack、host;后面的workload再引用这套定义。levels按从宽到窄的顺序定义层级,schedulerTopologyBindings声明这些层级和具体调度器侧拓扑资源之间的映射。如果没有显式绑定调度器侧拓扑资源,Grove operator可以从levels生成并管理;如果显式绑定了,就把对应资源当作外部管理对象,只做漂移检查。

示例:

apiVersion: grove.io/v1alpha1
kind: ClusterTopologyBinding
metadata:
  name: gpu-cluster-topology
spec:
  levels: # Grove暴露给workload使用的拓扑层级,从大到小
    - domain: zone
      key: topology.kubernetes.io/zone
    - domain: block
      key: node.network.kwai.io/block
    - domain: rack
      key: node.network.kwai.io/rack
    - domain: host
      key: kubernetes.io/hostname
  schedulerTopologyBindings: # 映射到具体调度器后端
    - schedulerName: kai-scheduler
      topologyReference: kai-gpu-cluster-topology

然后在PodCliqueSetPodCliqueScalingGroup里引用这个拓扑定义:

apiVersion: grove.io/v1alpha1
kind: PodCliqueSet
metadata:
  name: inference
spec:
  replicas: 2
  template:
    topologyConstraint: # PodCliqueSet副本尽量装到rack内
      topologyName: gpu-cluster-topology
      pack:
        preferred: rack

    cliques:
      - name: router
        spec:
          roleName: router
          replicas: 1
          podSpec:
            containers:
              - name: router
                image: router:v1

      - name: prefill
        spec:
          roleName: prefill
          replicas: 1
          podSpec:
            containers:
              - name: prefill
                image: prefill:v1
                resources:
                  requests:
                    nvidia.com/gpu: 2
                  limits:
                    nvidia.com/gpu: 2

      - name: decode
        spec:
          roleName: decode
          replicas: 2
          podSpec:
            containers:
              - name: decode
                image: decode:v1
                resources:
                  requests:
                    nvidia.com/gpu: 1
                  limits:
                    nvidia.com/gpu: 1

    podCliqueScalingGroups:
      - name: serving-cell
        cliqueNames:
          - prefill
          - decode
        replicas: 2
        minAvailable: 1
        topologyConstraint: # serving-cell内部强制装到同一个host域
          pack:
            required: host

这个例子想表达的是:拓扑不是散落在每个Pod的nodeAffinity里,而是作为workload的一部分表达。template.topologyConstraint.pack.preferred: rack表示每个PodCliqueSet replica尽量pack到一个rack内;serving-cell.topologyConstraint.pack.required: host表示每个serving-cell replica内部的相关组件必须pack到host这个拓扑域内。

这里还要注意一个API语义:pack.required/preferred控制的是每个replica的装箱约束。比如rack不是“把所有replicas放到同一个rack”,而是“每个replica各自尽量或必须放进一个rack”。不同replicas可以落在不同rack。

4. 把启动顺序和组件关系显式化

推理系统里经常有leader/worker、coordinator/worker、router/backend这类关系。不是所有Pod同时创建出来,系统就一定能稳定启动。某些worker需要等leader ready,某些后端需要先注册,某些组件需要等依赖服务就绪。

这些逻辑如果都写在业务脚本里,后面会很难维护。

Grove通过cliqueStartupTypestartsAfter把这类关系放进workload spec。cliqueStartupType有三个值:

  • CliqueStartupTypeAnyOrder:默认值,cliques可以并发启动;
  • CliqueStartupTypeInOrder:按cliques列表顺序启动;
  • CliqueStartupTypeExplicit:按每个PodClique.spec.startsAfter声明的依赖启动。

示例:

apiVersion: grove.io/v1alpha1
kind: PodCliqueSet
metadata:
  name: leader-worker-demo
spec:
  replicas: 1
  template:
    cliqueStartupType: CliqueStartupTypeExplicit # 使用显式依赖
    cliques:
      - name: leader
        spec:
          roleName: leader
          replicas: 1
          podSpec:
            containers:
              - name: leader
                image: leader:v1

      - name: worker
        spec:
          roleName: worker
          replicas: 4
          startsAfter: # worker等leader先启动
            - leader
          podSpec:
            containers:
              - name: worker
                image: worker:v1

这样,组件之间的启动顺序就不是脚本里的隐式约定,而是Kubernetes对象里的显式声明。Grove API还会校验startsAfter:如果依赖形成环,或者依赖了不存在的PodClique,会被当成非法spec。

附图:从PodCliqueSet到Pod

下图展示了PodCliqueSetPodCliqueScalingGroupPodCliquePodGang和实际Pod之间的关系。

Grove控制器创建和维护对象流程

小结

Grove的价值可以概括成一句话:它把复杂LLM推理系统从一堆Pod和YAML,整理成一个可声明、可扩缩、可表达组件关系的workload对象。

看Grove时,最值得拆解的不是某个字段,而是几个设计方向:

  • 推理服务应该有workload-level表达,而不是只靠单Pod;
  • prefill、decode、leader、worker这类组件关系应该显式建模;
  • gang scheduling需要分层,不能只有整个job all-or-nothing;
  • 拓扑约束应该写在服务结构里,而不是散落在每个Pod的nodeAffinity里;
  • 启动顺序和生命周期也应该成为workload spec的一部分。

Grove提供的是workload表达层。这个层次清楚之后,调度器才有机会按服务实例、组件组、拓扑域和最小可运行单元去做真正的资源决策。

参考资料

comments powered by Disqus