MoreRSS

site iconLixueduan | 李学端修改

博客名:指月小筑。专注云原生,Go,坚持分享最佳实践、经验干货。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

把 Ascend 310P3 切进 Kubernetes:HAMi vNPU 硬切实战

2026-09-28 06:00:00

HAMi Ascend 310P3 vNPU 硬切

前一篇《Ascend vNPU 实战:从原生 Docker 到 Ascend Docker Runtime》已经跑通了 vNPU 的两种用法,本文继续把这套能力接进 Kubernetes,用 HAMi 统一管理 vNPU。

310P3 的硬切并不是按任意比例切分显存和算力,而是从芯片支持的固定 vNPU 模板中选择一档。HAMi 会根据 Pod 的资源请求匹配合适的模板,由 Scheduler 选择具体设备并记录分配结果;随后 kubelet 调用 Ascend device-plugin 完成设备分配,最终由 Ascend Runtime 将对应的 vNPU 暴露给容器。

环境准备

实验环境

组件 版本或配置
操作系统 Ubuntu 20.04.6 LTS,x86_64
节点 单节点,lixd-npu-test2
NPU Ascend 310P3,1 张
Kubernetes v1.37.0
HAMi v2.10.0
ascend-device-plugin v1.4.1

版本说明:本文记录的是 Ascend Driver 22.0.4 环境下的模板硬切分实测。ascend-device-plugin v1.4.1 官方部署文档将 Ascend Driver ≥ 25.5 列为环境要求;本文环境低于该要求,实测结果仅对应本文所述环境,不代表官方支持或推荐的版本组合。

宿主机初始状态如下:

1
2
3
4
5
6
7
8
9
+--------------------------------------------------------------------------------------------------------+
| npu-smi 22.0.4 Version: 22.0.4 |
+-------------------------------+-----------------+------------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
+===============================+=================+======================================================+
| 7 310P3 | OK | NA 28 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 1805 / 21527 |
+===============================+=================+======================================================+

安装 HAMi 2.10

添加 HAMi Helm 仓库:

1
2
3
helm repo add hami-charts https://project-hami.github.io/HAMi/
helm repo update
helm search repo hami-charts/hami --versions | head

安装 HAMi 2.10,并开启 Ascend 设备支持:

1
2
3
4
helm install hami hami-charts/hami \
 --version 2.10.0 \
 -n kube-system \
 --set devices.ascend.enabled=true

devices.ascend.enabled=true 开启昇腾资源支持。

安装好后确认 HAMi 调度器已经正常运行:

1
2
3
root@lixd-npu-test2:~# kubectl -n kube-system get pod -l app.kubernetes.io/component=hami-scheduler
NAME READY STATUS RESTARTS AGE
hami-scheduler-7f554bd479-rsrb9 2/2 Running 0 3m33s

标记 Ascend 节点

昇腾的 device-plugin 默认通过 ascend=on 选择节点,因此需要给节点打上 label。

1
kubectl label node lixd-npu-test2 ascend=on --overwrite

部署 RuntimeClass

需要节点上已经装好 Ascend Docker Runtime 并注册了 ascend handler。

然后创建 RuntimeClass:

1
kubectl apply -f https://raw.githubusercontent.com/Project-HAMi/ascend-device-plugin/refs/tags/v1.4.1/ascend-runtimeclass.yaml

部署 ConfigMap

安装 HAMi 后已经自动创建了全局配置 hami-scheduler-device,其中包含 Ascend 的 resourceName、切分模式和 vNPU 模板,不需要重复部署。

后续 device-plugin 还会挂载 hami-device-node-config,因此需要先手动创建节点级 ConfigMap:

1
kubectl apply -f https://raw.githubusercontent.com/Project-HAMi/ascend-device-plugin/refs/tags/v1.4.1/ascend-device-node-configmap.yaml

节点级别的设置优先级高于全局。

部署 ascend-device-plugin

使用 v1.4.1 tag 下的官方 YAML 部署:

1
kubectl apply -f https://raw.githubusercontent.com/Project-HAMi/ascend-device-plugin/refs/tags/v1.4.1/ascend-device-plugin.yaml

v1.4.1 tag 中的 YAML 默认镜像仍然是 v1.4.0,因此部署后手动把 DaemonSet 镜像更新为 v1.4.1:

1
2
3
kubectl -n kube-system set image \
 daemonset/hami-ascend-device-plugin \
 device-plugin=projecthami/ascend-device-plugin:v1.4.1

确认 DevicePlugin 启动成功:

1
2
3
root@lixd-npu-test2:~# kubectl -n kube-system get pod -l app.kubernetes.io/component=hami-ascend-device-plugin
NAME READY STATUS RESTARTS AGE
hami-ascend-device-plugin-2z52v 1/1 Running 0 8m26s

DevicePlugin 启动后,验证节点资源情况:

1
kubectl describe node lixd-npu-test2 | grep "Capacity:" -A 7
1
2
3
4
5
6
7
8
Capacity:
 cpu: 16
 ephemeral-storage: 100676120576
 huawei.com/Ascend310P: 7
 hugepages-1Gi: 0
 hugepages-2Mi: 0
 memory: 65866796Ki
 pods: 110

为什么一张物理卡会显示 huawei.com/Ascend310P:7 ?

节点上出现的 huawei.com/Ascend310P: 7 是 HAMi/device-plugin 按最小 vNPU 模板折算后,向 Kubernetes 上报的可调度资源份额,不代表节点上有 7 张物理 NPU。

当前 310P3 配置中的最小模板是 vir01=3072 MiB,所以计算是 floor(21527 / 3072) = 7。这里使用的是 memoryAllocatable。

因此 device-plugin 会按最小模板折算虚拟设备数量,并向 Kubernetes 上报 Ascend310P: 7;具体 Pod 能否继续分配到这张物理卡,则由 HAMi 结合设备显存等资源进行判断。

HAMi vNPU 资源模型与模板

HAMi 里的资源

310P3 模板硬切使用设备数量和显存两个资源键:

1
2
3
4
resources:
 limits:
 huawei.com/Ascend310P: "1"
 huawei.com/Ascend310P-memory: "1024"
  • huawei.com/Ascend310P:Ascend 设备数量资源。模板硬切时设置为 1,再结合 -memory 选择 vNPU 模板;
  • huawei.com/Ascend310P-memory:显存请求。HAMi 会选择能够满足请求的最小模板;
  • 不指定 -memory 时表示申请整卡。

查看 310P3 支持的模板

查看 NPU 硬件支持的 vNPU 模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
root@lixd-npu-test2:~# npu-smi info -t template-info -i 7
+------------------------------------------------------------------------------------------+
|NPU instance template info is: |
|Name AICORE Memory AICPU VPC VENC JPEGD |
| GB PNGD VDEC JPEGE |
|==========================================================================================|
|vir01 1 3 1 1 0 2 |
| 0 1 1 |
+------------------------------------------------------------------------------------------+
|vir02 2 6 2 3 1 4 |
| 0 3 2 |
+------------------------------------------------------------------------------------------+
|vir02_1c 2 6 1 3 0 4 |
| 0 3 2 |
+------------------------------------------------------------------------------------------+
|vir04 4 12 4 6 2 8 |
| 0 6 4 |
+------------------------------------------------------------------------------------------+
|vir04_3c 4 12 3 6 1 8 |
| 0 6 4 |
+------------------------------------------------------------------------------------------+
|vir04_3c_ndvpp 4 12 3 0 0 0 |
| 0 0 0 |
+------------------------------------------------------------------------------------------+
|vir04_4c_dvpp 4 12 4 12 3 16 |
| 0 12 8 |
+------------------------------------------------------------------------------------------+

然后查看 HAMi 实际加载的 ConfigMap:

1
2
3
kubectl -n kube-system get cm hami-scheduler-device \
 -o jsonpath='{.data.device-config\.yaml}' \
 | grep -A24 'chipName: 310P3'

HAMi 当前针对 310P3 的默认配置只启用了三种模板:

模板 配置显存 AI Core AI CPU
vir01 3072 MiB 1 1
vir02 6144 MiB 2 2
vir04 12288 MiB 4 4

对应配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- chipName: 310P3
 commonWord: Ascend310P
 resourceName: huawei.com/Ascend310P
 resourceMemoryName: huawei.com/Ascend310P-memory
 resourceCoreName: huawei.com/Ascend310P-core
 memoryAllocatable: 21527
 memoryCapacity: 24576
 aiCore: 8
 aiCPU: 7
 runtimeClassName: ascend
 templates:
 - name: vir01
 memory: 3072
 aiCore: 1
 aiCPU: 1
 - name: vir02
 memory: 6144
 aiCore: 2
 aiCPU: 2
 - name: vir04
 memory: 12288
 aiCore: 4
 aiCPU: 4

Demo 1:单 Pod 申请 1024 MiB

先从最小场景开始,创建一个申请 1024 MiB 显存的 Pod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: v1
kind: Pod
metadata:
 name: auto-1024
spec:
 schedulerName: hami-scheduler
 runtimeClassName: ascend
 restartPolicy: Never
 containers:
 - name: npu-test
 image: docker.io/ascendai/cann:7.0.1-310p-openeuler20.03-py3.8
 imagePullPolicy: IfNotPresent
 command: ["bash", "-lc"]
 args:
 - |
 echo "ASCEND_VISIBLE_DEVICES=$ASCEND_VISIBLE_DEVICES"
 echo "ASCEND_VNPU_SPECS=$ASCEND_VNPU_SPECS"
 npu-smi info
 sleep 3600
 resources:
 limits:
 huawei.com/Ascend310P: "1"
 huawei.com/Ascend310P-memory: "1024"

确认 Pod 已经 Running:

1
kubectl get pod auto-1024 -o wide
1
2
NAME READY STATUS RESTARTS AGE IP NODE
auto-1024 1/1 Running 0 21s 172.25.49.46 lixd-npu-test2

HAMi 把这次请求匹配为 vir01,分配注解如下:

1
2
kubectl get pod auto-1024 \
 -o jsonpath='{.metadata.annotations.hami\.io/Ascend310P-devices-allocated}{"\n"}{.metadata.annotations.huawei\.com/Ascend310P}{"\n"}'
1
2
E0766E64-20C0E5F1-27941064-AED8030A-F6003019,Ascend310P,3072,0:;
[{"UUID":"E0766E64-20C0E5F1-27941064-AED8030A-F6003019","temp":"vir01","memory":3072}]

进入 Pod 查看环境变量和设备信息:

1
2
3
4
5
kubectl exec auto-1024 -- bash -lc '
 echo "ASCEND_VISIBLE_DEVICES=$ASCEND_VISIBLE_DEVICES"
 echo "ASCEND_VNPU_SPECS=$ASCEND_VNPU_SPECS"
 npu-smi info
'

完整输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ASCEND_VISIBLE_DEVICES=0
ASCEND_VNPU_SPECS=vir01
+--------------------------------------------------------------------------------------------------------+
| npu-smi 22.0.4 Version: 22.0.4 |
+-------------------------------+-----------------+------------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
+===============================+=================+======================================================+
| 7 310Pvir01 | OK | NA 28 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 249 / 2690 |
+===============================+=================+======================================================+

Pod 已经 Running,ASCEND_VNPU_SPECS=vir01、分配注解里的 temp: vir01 和 Pod 内的 310Pvir01 三处证据也能互相对应,说明最基本的硬切链路正常。

Demo 2:不同显存请求与模板匹配

接下来验证 HAMi 是否真的会按请求自动选择模板。我分别申请 1024、4096 和 7000 MiB,这些值都不是模板的精确显存。

三个 Pod 全部 Running:

1
2
3
4
NAME READY STATUS NODE
auto-1024 1/1 Running lixd-npu-test2
auto-4096 1/1 Running lixd-npu-test2
auto-7000 1/1 Running lixd-npu-test2

匹配结果如下:

原始申请 webhook 调整后 HAMi 选择模板 容器内可见容量
1024 MiB 3072 MiB vir01 2690 MiB
4096 MiB 6144 MiB vir02 5381 MiB
7000 MiB 12288 MiB vir04 10763 MiB

4096 MiB 请求在 Pod 内显示为 vir02:

1
2
3
4
5
6
ASCEND_VISIBLE_DEVICES=0
ASCEND_VNPU_SPECS=vir02
+===============================+=================+======================================================+
| 7 310Pvir02 | OK | NA 28 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 497 / 5381 |
+===============================+=================+======================================================+

7000 MiB 请求在 Pod 内显示为 vir04:

1
2
3
4
5
6
ASCEND_VISIBLE_DEVICES=0
ASCEND_VNPU_SPECS=vir04
+===============================+=================+======================================================+
| 7 310Pvir04 | OK | NA 29 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 995 / 10763 |
+===============================+=================+======================================================+

宿主机同时看到三个 vNPU:

1
2
3
4
5
6
7
8
| Total number of vnpu: 3 |
+-------------------------------------------------------------------------------+
| Vnpu ID | Vgroup ID | Container ID | Status | Template Name |
+-------------------------------------------------------------------------------+
| 100 | 0 | ffffffffffff | 1 | vir01 |
| 101 | 1 | ffffffffffff | 1 | vir02 |
| 102 | 2 | ffffffffffff | 1 | vir04 |
+-------------------------------------------------------------------------------+

这说明 HAMi 会选择能够满足请求的最小模板。

admission webhook 还会把 Pod 里的显存值改成选中模板的配置值,因此 Pod 创建后再执行 kubectl get pod -o yaml,看到的是 3072/6144/12288。 模板配置显存与容器中 npu-smi 显示的可用容量并不完全相等,这是 Ascend vNPU 自身的保留开销。

Demo 3:超过最大模板后的整卡回退——以 13000 MiB 为例

310P3 最大模板是 vir04=12288 MiB。删除前面的切片并确认 NPU 空闲后,我又申请了 13000 MiB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Pod
metadata:
 name: over-max-memory-idle-card
spec:
 schedulerName: hami-scheduler
 runtimeClassName: ascend
 restartPolicy: Never
 containers:
 - name: npu-test
 image: docker.io/ascendai/cann:7.0.1-310p-openeuler20.03-py3.8
 command: ["bash", "-lc"]
 args:
 - env | grep -E 'ASCEND|NPU' | sort; npu-smi info; sleep 300
 resources:
 limits:
 huawei.com/Ascend310P: "1"
 huawei.com/Ascend310P-memory: "13000"

Pod 成功调度并运行:

1
2
3
4
5
pod/over-max-memory-idle-card created
pod/over-max-memory-idle-card condition met

NAME READY STATUS RESTARTS AGE IP NODE
over-max-memory-idle-card 1/1 Running 0 3m15s 172.25.49.59 lixd-npu-test2

再看 API Server 中保存的资源和 HAMi 分配注解,原始的 13000 MiB 已经被 webhook 提升为整卡可调度显存 21527 MiB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
 "allocated": "E0766E64-20C0E5F1-27941064-AED8030A-F6003019,Ascend310P,21527,0:;",
 "device": "[{\"UUID\":\"E0766E64-20C0E5F1-27941064-AED8030A-F6003019\",\"memory\":21527}]",
 "resources": {
 "limits": {
 "huawei.com/Ascend310P": "1",
 "huawei.com/Ascend310P-memory": "21527"
 },
 "requests": {
 "huawei.com/Ascend310P": "1",
 "huawei.com/Ascend310P-memory": "21527"
 }
 }
}

Pod 内没有 ASCEND_VNPU_SPECS,npu-smi info 看到的是完整的物理 310P3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
ASCEND_AICPU_PATH=/usr/local/Ascend/ascend-toolkit/latest
ASCEND_DOCKER_RUNTIME=True
ASCEND_HOME_PATH=/usr/local/Ascend/ascend-toolkit/latest
ASCEND_OPP_PATH=/usr/local/Ascend/ascend-toolkit/latest/opp
ASCEND_TOOLKIT_HOME=/usr/local/Ascend/ascend-toolkit/latest
ASCEND_VISIBLE_DEVICES=0
+--------------------------------------------------------------------------------------------------------+
| npu-smi 22.0.4 Version: 22.0.4 |
+-------------------------------+-----------------+------------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
+===============================+=================+======================================================+
| 7 310P3 | OK | NA 28 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 1805 / 21527 |
+===============================+=================+======================================================+

宿主机同时显示 Total number of vnpu: 0。所以 13000 MiB 并没有匹配到一个更大的 vNPU,而是被转换成整卡申请。空闲卡上可以运行;卡上已有切片时,同样的请求会因 CardInsufficientMemory 保持 Pending。

Demo 4:多 Pod 共卡与容量耗尽

单 Pod 跑通只能证明链路没断,真正使用时更关心多个任务能不能共卡。

两个 vir01 Pod 同时运行时,HAMi 给出的物理设备 UUID 相同,两个容器内都看到独立的 310Pvir01。随后我把 Deployment 副本数调到 8,每个副本都申请一个 vir01。

结果很直接:

1
2
NAME READY UP-TO-DATE AVAILABLE
hami-310p-oversubscribe 7/8 8 7

7 个 Pod Running,第 8 个一直 Pending:

1
2
Warning FailedScheduling hami-scheduler
0/1 nodes are available: 1 1/1 CardTimeSlicingExhausted.

宿主机也正好看到 7 个 vir01:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
| Total number of vnpu: 7 |
+-------------------------------------------------------------------------------+
| 100 | 0 | ffffffffffff | 1 | vir01 |
| 101 | 0 | ffffffffffff | 1 | vir01 |
| 102 | 1 | ffffffffffff | 1 | vir01 |
| 103 | 1 | ffffffffffff | 1 | vir01 |
| 104 | 2 | ffffffffffff | 1 | vir01 |
| 105 | 2 | ffffffffffff | 1 | vir01 |
| 106 | 3 | ffffffffffff | 1 | vir01 |
+-------------------------------------------------------------------------------+

这里同时验证了 Kubernetes 上报的 Ascend310P: 7 确实对应七个最小模板,以及容量用尽后 HAMi 会给出明确的 CardTimeSlicingExhausted,而不是把第 8 个 Pod 错误绑到节点。

Demo 5:整卡与切片双向互斥

不设置 huawei.com/Ascend310P-memory 就表示申请整卡:

1
2
3
resources:
 limits:
 huawei.com/Ascend310P: "1"

webhook 会自动补成整卡可调度显存:

1
huawei.com/Ascend310P-memory: "21527"

我把两个方向都测了一遍。

整卡 Pod 先运行,再创建 vir01 切片,切片 Pod Pending:

1
0/1 nodes are available: 1 1/1 CardInsufficientMemory.

反过来,vir01 + vir02 + vir04 已经运行时再申请整卡,整卡 Pod 同样 Pending:

1
2
3
4
5
6
7
8
NAME READY STATUS
auto-1024 1/1 Running
auto-4096 1/1 Running
auto-7000 1/1 Running
whole-after-slices 0/1 Pending

Warning FailedScheduling hami-scheduler
0/1 nodes are available: 1 1/1 CardInsufficientMemory.

所以整卡与切片的互斥在 HAMi 2.10 上仍然成立,调度器会通过设备显存记账阻止两类工作负载同时使用同一张卡。

一个容易踩的坑:残留 vNPU

这一步第一次跑的时候,8 个 vir01 只有 3 个真的跑起来,另外 4 个一直 CrashLoopBackOff,容器根本没启动:

1
2
failed to create containerd task: ... /usr/local/Ascend/Ascend-Docker-Runtime/ascend-docker-runtime
did not terminate successfully: exit status 1

调度器认为卡是空的(前一步的 Pod 都删掉了),但硬件上并不是。原因是这个驱动上删除 Pod 不一定会立刻释放 vNPU,上一步"超过模板转整卡"的 Pod 在卡上留了一个 vir04 实例:

1
2
3
4
5
6
| Total number of vnpu: 4 |
| Vnpu ID | Vgroup ID | Container ID | Status | Template Name |
| 100 | 0 | 000000000000 | 0 | vir01 |
| 101 | 0 | 000000000000 | 0 | vir01 |
| 102 | 2 | 000000000000 | 0 | vir04 | <- 残留
| 103 | 1 | 000000000000 | 0 | vir01 |

残留的 vir04 占掉 12 GiB,卡上只剩 2 GiB 可用,所以第 4 个 vir01 开始就创建失败。判断方法是对比两个视角:npu-smi info -t info-vnpu 里 Container ID 是 000000000000 的条目就是没人认领的残留实例,而 npu-smi info 的剩余显存会明显低于 HAMi 仍在播报的容量。

清理方式是重启设备插件触发它的空闲 vNPU 回收,或者直接销毁:

1
2
kubectl -n kube-system rollout restart daemonset/hami-ascend-device-plugin
npu-smi set -t destroy-vnpu -i 7 -c 0 -v VNPU_ID

清理干净后重跑,结果就和预期一致:7 个 vir01 全部 Running,第 8 个 Pending,卡上正好 7 个 vir01 实例。所以每一步之间先删掉上一步的 Pod 并等 vNPU 释放,否则下一步看起来就像容量算错了。

Demo 6:Pod 内运行真实 AscendC 负载

显存实际分配与释放

前面的 Pod 都只运行 npu-smi 和 sleep,最多证明设备能创建出来。

为了确认 vNPU 里真的可以使用显存,我在 vir04 容器中通过 ACL 初始化设备并申请约 6 GiB 内存。空闲时 Pod 内看到:

1
2
| 7 310Pvir04 | OK | NA 33 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 995 / 10763 |

分配期间变成:

1
2
3
4
+===============================+=================+======================================================+
| 7 310Pvir04 | OK | NA 33 3092 / 3092 |
| 0 0 | 0000:00:07.0 | 0 10763/ 10763 |
+===============================+=================+======================================================+

释放后又恢复到:

1
2
| 7 310Pvir04 | OK | NA 33 0 / 0 |
| 0 0 | 0000:00:07.0 | 0 995 / 10763 |

这个结果证明 ACL 工作负载可以在 vir04 内分配和释放设备内存,也说明该 vir04 容器中的设备视图和显存分配受对应 vNPU 容量约束。

宿主机物理卡的 Memory-Usage 没有按相同幅度增长,因此不能用宿主机这一个字段反推单个硬切 Pod 的实时显存。310P3 旧驱动下,vNPU 内部统计、HugePages 和物理卡统计的口径并不相同。

编译并运行 CANN 自带的 AscendC 基础算子

显存能申请还不够,我又在同一个 vir04 中编译并运行了 CANN 自带的 AscendC 基础算子。

构建过程生成了 AI Core 目标文件并完成链接:

1
2
3
4
[100%] Building CXX object ... auto_gen_add_custom.cpp.o
[100%] Building CXX object ... auto_gen_matmul_custom.cpp.o
/usr/local/Ascend/ascend-toolkit/latest/compiler/ccec_compiler/bin/ld.lld -m aicorelinux ...
[100%] Built target main

加法算子输出:

1
2
output of add_custom:
8.000000 8.000000 8.000000 8.000000 ...

矩阵乘算子输出:

1
2
output of matmul:
8192.000000 8192.000000 8192.000000 8192.000000 ...

循环运行期间,宿主机采样到过 20% 的 AICore 利用率:

1
2
3
4
5
20:51:39.865
| 7 310P3 | OK | NA 34 20 / 20 |

20:51:41.424
| 7 310P3 | OK | NA 34 0 / 0 |

这是短算子脉冲负载,所以采样值在 0% 和 20% 之间跳动。它能证明任务确实进入 AI Core 执行,不能据此宣称 vir04 始终占用固定百分比算力。

Demo 7:HAMi 调度侧监控与资源分配指标

HAMi scheduler 容器内部监听 9395,Service 通过 NodePort 31993 暴露 Prometheus 指标:

1
2
curl -s http://NODE_IP:31993/metrics \
 | grep -E 'hami_(gpu_shared_count|vgpu_memory_allocated_bytes|resource_quota_used)'

三个模板同时运行时:

1
2
3
4
5
6
7
8
9
hami_gpu_shared_count{
 device_type="Ascend310P",
 node="lixd-npu-test2"
} 3

hami_resource_quota_used{
 namespace="hami-hard-slice-test",
 quota_name="huawei.com/Ascend310P-memory"
} 21504

Pod 维度的分配量也能看到:

1
2
3
hami_vgpu_memory_allocated_bytes{pod="auto-1024",namespace="hami-hard-slice-test"} 3.221225472e+09
hami_vgpu_memory_allocated_bytes{pod="auto-4096",namespace="hami-hard-slice-test"} 6.442450944e+09
hami_vgpu_memory_allocated_bytes{pod="auto-7000",namespace="hami-hard-slice-test"} 1.2884901888e+10

这三个值分别是 3072、6144 和 12288 MiB 换算成字节后的结果,属于调度分配量,不是 Pod 的实时使用量。

小结

这次在 Ascend 310P3 上,从 HAMi 2.10、ascend-device-plugin v1.4.1 到 Pod 内运行 AscendC,完整走通了模板硬切链路。 使用上也比较简单,只需要通过 -memory 指定显存,HAMi 就会自动匹配满足条件的最小 vNPU 模板。

Ascend vNPU 实战:从原生 Docker 到 Ascend Docker Runtime

2026-09-22 06:00:00

Ascend vNPU 实战:从原生 Docker 到 Ascend Docker Runtime

本文以 Ascend 310P3(Atlas 300I Pro)为例,介绍昇腾 NPU vNPU 的基本使用方法。 内容包括从原生 Docker 开始,演示手动创建 vNPU 并挂载到容器,以及 Ascend Docker Runtime 下的静态挂载和动态虚拟化。

后续我们会继续把 vNPU 接入 Kubernetes,并进一步看 HAMi 如何对 vNPU 做统一的资源管理和调度。本文先绕开 Kubernetes 和 HAMi,从 Ascend 自身提供的 vNPU 能力开始,跑通 vNPU。

vNPU 介绍

什么是 Ascend vNPU

昇腾的虚拟化实例叫 AVI(Ascend Virtual Instance)。它可以按照固定的虚拟化模板,把一个物理 NPU 划分成多个 vNPU,再把 vNPU 提供给容器使用。

从使用者的角度,原生 Docker 下主要需要完成三件事:

  1. 选择虚拟化模式和 vNPU 模板;
  2. 创建或指定要使用的 vNPU;
  3. 把设备(vNPU)挂载到容器使用。

昇腾 NPU 虚拟化架构

和 NVIDIA MIG 的类比

Ascend vNPU 在使用方式上可以类比 NVIDIA MIG(Multi-Instance GPU):两者都可以把一块物理加速卡的资源划分成更小的实例,再分别提供给容器或任务使用。

不过这只是使用方式上的类比。Ascend vNPU 与 NVIDIA MIG 在资源模型、虚拟化机制和具体实现上并不相同,因此不能简单把两者视为同一种底层技术。

对比维度 Ascend vNPU NVIDIA MIG
虚拟实例 AVI / vNPU MIG Instance
划分方式 根据 vir01、vir02 等模板划分 AICORE、内存、AICPU 和媒体处理资源 根据 GPU 支持的 profile 划分计算核心、显存和相关硬件资源
创建/注入方式 使用 npu-smi 创建静态 vNPU;Ascend Docker Runtime 可按 ASCEND_VNPU_SPECS 动态创建并注入,或挂载已有 vNPU 使用 nvidia-smi mig / NVML 创建 MIG 实例;NVIDIA Container Toolkit 负责将已有 MIG 设备注入容器
容器视角 容器内看到一个 310Pvir01 等 vNPU 设备 容器内看到分配到的 MIG GPU
共同点 都可以把物理加速器资源以更细粒度的实例提供给不同容器或任务使用 都可以把物理加速器资源以更细粒度的实例提供给不同容器或任务使用

本文后面的命令只针对 Ascend vNPU,NVIDIA MIG 仅作为帮助理解的参照。

不同场景下的使用方式

vNPU 的底层能力相同,但不同运行环境负责的事情不一样:

  • 原生 Docker:通过 npu-smi 手动创建 vNPU,再手动把 vNPU 设备和 Ascend 驱动环境挂载到容器中,下面会完整展开。

  • Ascend Docker Runtime:支持静态和动态两种方式。静态方式仍然需要提前创建 vNPU,但 vNPU 设备注入由 Runtime 完成;动态方式则在启动容器时通过 ASCEND_VISIBLE_DEVICES、ASCEND_VNPU_SPECS 等参数指定物理设备和 vNPU 模板,由 Runtime 动态创建并注入 vNPU,不需要提前创建虚拟实例。

  • 集群调度组件:可以使用 Ascend Device Plugin 配合 Kubernetes Scheduler、Volcano 或其他调度组件。Device Plugin 负责设备发现、资源上报和节点侧设备分配,Scheduler 根据资源请求选择合适的节点;HAMi、Volcano 等增强方案还可以进一步参与资源调度和管理。

简单说,原生 Docker 是“用户自己创建、自己挂载设备”,Ascend Docker Runtime 是“Runtime 帮你注入设备”,集群调度则是在此基础上进一步统一管理资源。

Ascend vNPU 三种 Docker 使用方式

测试环境

本次使用环境关键信息如下:

项目 实测值
操作系统 Huawei Cloud EulerOS 2.0(x86_64)
主机名 lixd-test-npu
NPU Ascend 310P3 / Atlas 300I Pro,1 张
架构 x86_64
Ascend Driver 22.0.4
CANN 6.0.0
Docker docker-engine 18.09.0
Ascend Docker Runtime 6.0.0

查询 NPU 和 vNPU 模板

先确认 NPU 设备和芯片信息:

1
2
npu-smi info -l
npu-smi info -m
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	Total Count : 1

	NPU ID : 13
	Product Name : IT21PDDA01
	Serial Number : 2106030635ZENC0xxx94
	Chip Count : 1

	NPU ID Chip ID Chip Logic ID Chip Name
	13 0 0 Ascend 310P3
	13 1 - Mcu

本机的关键结果是:

1
2
3
4
NPU ID: 13
Chip ID: 0
Chip Name: Ascend 310P3
Product Name: Atlas 300I Pro

再确认产品类型:

1
npu-smi info -t product -i 13
1
	Product Type : Atlas 300I Pro

再查询当前产品支持的 vNPU 模板:

1
npu-smi info -t template-info -i 13
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
+------------------------------------------------------------------------------------------+
|NPU instance template info is: |
|Name AICORE Memory AICPU VPC VENC JPEGD |
| GB PNGD VDEC JPEGE |
|==========================================================================================|
|vir01 1 3 1 1 0 2 |
| 0 1 1 |
+------------------------------------------------------------------------------------------+
|vir02 2 6 2 3 1 4 |
| 0 3 2 |
+------------------------------------------------------------------------------------------+
|vir02_1c 2 6 1 3 0 4 |
| 0 3 2 |
+------------------------------------------------------------------------------------------+
|vir04 4 12 4 6 2 8 |
| 0 6 4 |
+------------------------------------------------------------------------------------------+
|vir04_3c 4 12 3 6 1 8 |
| 0 6 4 |
+------------------------------------------------------------------------------------------+
|vir04_3c_ndvpp 4 12 3 0 0 0 |
| 0 0 0 |
+------------------------------------------------------------------------------------------+
|vir04_4c_dvpp 4 12 4 12 3 16 |
| 0 12 8 |
+------------------------------------------------------------------------------------------+

本机可用的模板包括 vir01、vir02、vir02_1c、vir04、vir04_3c、vir04_3c_ndvpp 和 vir04_4c_dvpp。本文选择 vir01 做最小验证。

原生 Docker 下的 vNPU Demo

原生 Docker 下使用 vNPU 分成两步:先用 npu-smi 创建 vNPU,再把生成的 vNPU 设备映射到容器。

安装 Docker

使用 dnf 安装 Docker。Huawei Cloud EulerOS 仓库里的包名是 docker-engine:

1
2
dnf install -y docker-engine
systemctl enable --now docker

确认 Docker 服务和服务端信息:

1
2
docker version
docker info --format 'server={{.ServerVersion}} arch={{.Architecture}} default-runtime={{.DefaultRuntime}}'
1
server=18.09.0 arch=x86_64 default-runtime=runc

创建 vNPU

本机使用 NPU ID 13、Chip ID 0 和 vir01 模板:

1
2
npu-smi set -t create-vnpu -i 13 -c 0 -f vir01
npu-smi info -t info-vnpu -i 13 -c 0

输出如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
	Status : OK
	Message : Create vnpu success

+-------------------------------------------------------------------------------+
| NPU resource static info as follow: |
| Format:Free/Total NA: Currently, query is not supported. |
| AICORE Memory AICPU VPC VENC VDEC JPEGD JPEGE PNGD |
| GB |
|===============================================================================|
| 7/8 18/21 6/7 11/12 3/3 11/12 14/16 7/8 NA/NA |
+-------------------------------------------------------------------------------+
| Total number of vnpu: 1 |
+-------------------------------------------------------------------------------+
| Vnpu ID | Vgroup ID | Container ID | Status | Template Name |
+-------------------------------------------------------------------------------+
| 100 | 0 | 000000000000 | 0 | vir01 |
+-------------------------------------------------------------------------------+

创建成功后,本机得到 vNPU ID 100,对应设备节点为 /dev/vdavinci100。

这里有几个容易混淆的设备编号:

  • npu-smi NPU ID:宿主机通过 npu-smi 查询和管理设备时使用的编号,例如本文中的 13,创建 vNPU 时通过 -i 13 指定;
  • Runtime 物理设备 ID:Ascend Docker Runtime 使用 /dev/davinci* 对应的设备序号选择物理设备。本文对应 /dev/davinci0,因此动态虚拟化时使用 ASCEND_VISIBLE_DEVICES=0;
  • vNPU ID:创建 vNPU 后返回的虚拟实例编号,例如本文中的 100,对应 /dev/vdavinci100。使用 Runtime 挂载已有 vNPU 时,通过 ASCEND_VISIBLE_DEVICES=100 指定;
  • 容器内显示编号:设备注入容器后,npu-smi 还会显示容器视角下的设备编号,例如本文实测中的 NPU 13。

这些编号来自不同的设备管理视角,不应该直接混用。

启动容器挂载 vNPU

启动容器前,先确认 vNPU 设备节点和基础设备节点已经存在:

1
2
3
4
ls -l /dev/vdavinci100 \
 /dev/davinci_manager \
 /dev/devmm_svm \
 /dev/hisi_hdc

本次实测可以看到以下设备节点:

1
2
3
4
crw-rw---- 1 HwHiAiUser HwHiAiUser 239, 0 Sep 17 17:42 /dev/davinci_manager
crw-rw---- 1 HwHiAiUser HwHiAiUser 234, 0 Sep 17 17:42 /dev/devmm_svm
crw-rw---- 1 HwHiAiUser HwHiAiUser 511, 0 Sep 17 17:42 /dev/hisi_hdc
crw------- 1 HwHiAiUser HwHiAiUser 236, 100 Sep 17 20:49 /dev/vdavinci100

然后我们启动容器,使用 --device 参数把 vNPU 挂载到容器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
IMG=ascendai/cann:7.0.1-310p-openeuler20.03-py3.8

docker run --rm \
 --device=/dev/vdavinci100:/dev/davinci100 \
 --device=/dev/davinci_manager \
 --device=/dev/devmm_svm \
 --device=/dev/hisi_hdc \
 -v /usr/local/sbin/npu-smi:/usr/local/sbin/npu-smi:ro \
 -v /usr/local/Ascend:/usr/local/Ascend:ro \
 -e LD_LIBRARY_PATH=/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/driver/lib64/driver:/usr/local/Ascend/driver/lib64 \
 $IMG \
 npu-smi info

容器内输出如下:

1
2
3
4
5
6
7
8
9
+--------------------------------------------------------------------------------------------------------+
| npu-smi 22.0.4 Version: 22.0.4 |
+-------------------------------+-----------------+------------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
+===============================+=================+======================================================+
| 13 310Pvir01 | OK | NA 56 0 / 0 |
| 0 0 | 0000:00:0D.0 | 0 312 / 2690 |
+===============================+=================+======================================================+

输出中的 310Pvir01 表明容器内已经看到 vir01 类型的 vNPU。

这里的参数可以简单分为两类。它们不是用来创建 vNPU 的,vNPU 已经在前一步创建完成:

  • 设备映射(--device):把 /dev/vdavinci100 以及 /dev/davinci_manager、/dev/devmm_svm、/dev/hisi_hdc 等 Ascend 设备节点交给容器, 这样容器才能访问已经创建的 vNPU。
  • 工具和运行库(-v、LD_LIBRARY_PATH):把宿主机的 npu-smi 和 /usr/local/Ascend 驱动/工具库以只读方式提供给容器,并让动态链接器能找到这些库。

Ascend Docker Runtime Demo

安装 Ascend Docker Runtime

原生 Docker 路径验证完成后,再安装 Ascend Docker Runtime,继续验证 Runtime 的静态挂载和动态虚拟化。

本次使用 MindCluster 6.0.0 提供的 x86_64 安装包。官方安装包地址见 MindCluster v6.0.0 Release。

1
2
3
4
5
6
7
8
9
mkdir -p /tmp/ascend-docker-runtime
cd /tmp/ascend-docker-runtime

wget -O Ascend-docker-runtime_6.0.0_linux-x86_64.run \
 https://gitee.com/ascend/mind-cluster/releases/download/v6.0.0/Ascend-docker-runtime_6.0.0_linux-x86_64.run

sha256sum Ascend-docker-runtime_6.0.0_linux-x86_64.run
chmod u+x Ascend-docker-runtime_6.0.0_linux-x86_64.run
./Ascend-docker-runtime_6.0.0_linux-x86_64.run --install

命令输出如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
2e3800050d4203daa87937748a436ba0c32fabbc8693ac597ee40ba5b6f7c6af Ascend-docker-runtime_6.0.0_linux-x86_64.run
Uncompressing ascend-docker-runtime 0% 57% 100%
[INFO] installing ascend docker runtime
[INFO] platform(x86_64) matched!
[INFO] install executable files success
[INFO] install scene is 'docker'.
[INFO] /etc/docker/daemon.json modify success
[INFO] Ascend Docker Runtime has been installed in: /usr/local/Ascend/Ascend-Docker-Runtime
[INFO] The version of Ascend Docker Runtime is: 6.0.0
[INFO] please reboot daemon and container engine to take effect
[INFO] Ascend Docker Runtime install success

安装完成后重载并重启 Docker:

1
2
3
systemctl daemon-reload
systemctl restart docker
docker info --format 'server={{.ServerVersion}} arch={{.Architecture}} default-runtime={{.DefaultRuntime}}'
1
server=18.09.0 arch=x86_64 default-runtime=ascend

Runtime 安装时会把 ascend 注册到 /etc/docker/daemon.json 并设为默认 runtime:

1
2
3
4
5
6
7
8
9
{
 "default-runtime": "ascend",
 "runtimes": {
 "ascend": {
 "path": "/usr/local/Ascend/Ascend-Docker-Runtime/ascend-docker-runtime",
 "runtimeArgs": []
 }
 }
}

Ascend Docker Runtime:静态 vNPU

安装 Runtime 后,可以复用前面通过 npu-smi 创建的静态 vNPU。与原生 Docker 的区别是:不再手工写 --device,而是把 vNPU ID 交给 Runtime,由 Runtime 完成设备注入。

本机前面创建的 vNPU ID 是 100。设置 ASCEND_RUNTIME_OPTIONS=VIRTUAL,告诉 Runtime ASCEND_VISIBLE_DEVICES 指向的是已经存在的 vNPU:

1
2
3
4
5
6
7
IMG=ascendai/cann:7.0.1-310p-openeuler20.03-py3.8

docker run --rm --runtime=ascend \
 -e ASCEND_VISIBLE_DEVICES=100 \
 -e ASCEND_RUNTIME_OPTIONS=VIRTUAL \
 $IMG \
 npu-smi info

容器内输出如下:

1
2
3
4
5
6
7
8
9
+--------------------------------------------------------------------------------------------------------+
| npu-smi 22.0.4 Version: 22.0.4 |
+-------------------------------+-----------------+------------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
+===============================+=================+======================================================+
| 13 310Pvir01 | OK | NA 56 0 / 0 |
| 0 0 | 0000:00:0D.0 | 0 312 / 2690 |
+===============================+=================+======================================================+

相比原生 Docker,不再需要手工声明 –device、挂载宿主机驱动目录或配置 LD_LIBRARY_PATH。在使用 Ascend Runtime 后,只需要通过 ASCEND_VISIBLE_DEVICES 和 ASCEND_RUNTIME_OPTIONS 描述需要注入的 vNPU。Runtime 在 prestart hook 阶段自动完成三件事:

  • 把 ASCEND_VISIBLE_DEVICES 对应的 NPU 设备挂载到容器;
  • 在宿主机上配置该容器的 device cgroup,保证设备隔离;
  • 将宿主机相关的 Ascend 驱动运行库和工具按 Runtime 配置注入容器。

也就是说,Runtime 已经解决了原生 Docker 里最麻烦的那部分。

Ascend Docker Runtime:动态 vNPU

Ascend Docker Runtime 可以在容器启动时根据环境变量自动创建 vNPU,并在容器退出后释放动态实例。动态验证和上面的静态验证是两条独立路径,动态启动时设备从没有预创建 vNPU 的状态开始。

因此,先确认前面的静态 vNPU 容器已经退出,再销毁 vNPU 100,并确认物理 NPU 上已经没有预创建实例:

1
2
npu-smi set -t destroy-vnpu -i 13 -c 0 -v 100
npu-smi info -t info-vnpu -i 13 -c 0

关闭 vNPU 配置恢复

动态 Runtime 需要关闭 vNPU 配置恢复功能:

1
npu-smi set -t vnpu-cfg-recover -d 0

输出如下:

1
2
	Status : OK
	Message : The VNPU config recover mode Disable is set successfully.

启动动态 vNPU 容器

通过 ASCEND_VISIBLE_DEVICES 选择物理设备,通过 ASCEND_VNPU_SPECS 指定 vNPU 模板。

这里的 ASCEND_VISIBLE_DEVICES=0 对应宿主机 /dev/davinci0,与前面 npu-smi -i 13 使用的 NPU ID 属于不同的设备编号视角。

1
2
3
4
5
docker run --rm --runtime=ascend \
 -e ASCEND_VISIBLE_DEVICES=0 \
 -e ASCEND_VNPU_SPECS=vir01 \
 $IMG \
 npu-smi info

容器内输出如下:

1
2
3
4
5
6
7
8
9
+--------------------------------------------------------------------------------------------------------+
| npu-smi 22.0.4 Version: 22.0.4 |
+-------------------------------+-----------------+------------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
+===============================+=================+======================================================+
| 13 310Pvir01 | OK | NA 56 0 / 0 |
| 0 0 | 0000:00:0D.0 | 0 312 / 2690 |
+===============================+=================+======================================================+

输出中的 310Pvir01 表明 Runtime 已经在容器内注入动态创建的 vir01 vNPU。

容器退出后再次查询:

1
npu-smi info -t info-vnpu -i 13 -c 0

输出如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
+-------------------------------------------------------------------------------+
| NPU resource static info as follow: |
| Format:Free/Total NA: Currently, query is not supported. |
| AICORE Memory AICPU VPC VENC VDEC JPEGD JPEGE PNGD |
| GB |
+===============================================================================+
| 8/8 21/21 7/7 12/12 3/3 12/12 16/16 8/8 NA/NA |
+-------------------------------------------------------------------------------+
| Total number of vnpu: 0 |
+-------------------------------------------------------------------------------+
| Vnpu ID | Vgroup ID | Container ID | Status | Template Name |
+-------------------------------------------------------------------------------+

结果显示当前 vNPU 数量为 0,说明动态实例随容器生命周期自动释放,AICORE 和内存也都恢复到 8/8、21/21。

小结

Docker 方式的职责可以概括为:

方式 设备和模板 容器内注入
原生 Docker 静态 vNPU 用户执行 npu-smi 创建 用户手工 --device,并挂载 npu-smi 和驱动库
Ascend Docker Runtime 静态 vNPU 用户执行 npu-smi 创建,Runtime 接收 vNPU ID Runtime 根据 ASCEND_VISIBLE_DEVICES 和 ASCEND_RUNTIME_OPTIONS=VIRTUAL 自动注入
Ascend Docker Runtime 动态 vNPU 启动容器时设置 ASCEND_VISIBLE_DEVICES、ASCEND_VNPU_SPECS Runtime 自动创建实例并注入,容器退出后释放

两者的核心差别在于谁负责把设备塞进容器:原生 Docker 靠用户手写 --device 和挂载,Ascend Docker Runtime 把这些交给 prestart hook 自动完成。这两种方式使用的是同一套底层 vNPU 能力。

到这里,我们先把 Ascend 自身提供的 vNPU 能力和两种 Docker 使用方式跑通了。下一步再把这套能力放进 Kubernetes,看看 Ascend Device Plugin 和 HAMi 分别解决什么问题。

vLLM PD 分离实战:从请求流程理解 Prefill、Decode 与 KV Cache 传输

2026-09-15 04:00:00

vLLM PD 分离实战:Prefill、Decode 与 KV Cache 传输

上一篇 https://www.lixueduan.com/posts/ai/27-why-pd-disaggregation/ 介绍了为什么要把 Prefill 和 Decode 拆开。本文继续往下走,在单节点 GPU 环境中搭建一个可以从头复现的 PD 分离 Demo,先用最小实现把整个链路跑通。

本文不引入复杂组件,只使用 vLLM 和一个轻量 Proxy,把 Prefill 与 Decode 两个阶段串起来。

Demo 主要看两件事:

  • PD 分离后的请求执行流程,观察同一个请求如何依次经过 Proxy、Prefill 和 Decode;
  • PD 分离中的 KV Cache 数据流,确认 Prefill 生成的 KV Cache 如何通过 KV Connector/NIXL 交给 Decode。

TL;DR

请求先由 Proxy 发送到 Prefill 计算 Prompt 和 KV Cache,再携带 kv_transfer_params 请求 Decode;KV Cache 由 Decode 通过 NIXL 直接从 Prefill 拉取,不经过 Proxy。

vLLM PD 分离应用的请求流程

1. 环境准备

1.1 准备 GPU 环境

本文不展开 GPU 基础环境的安装过程,大家可以先参考前置文章了解 GPU 环境搭建指南:如何在物理机、Docker、K8s 等环境中使用 GPU;

1.2 准备模型

从 ModelScope 下载模型,为了简化直接使用 Qwen2.5-0.5B-Instruct 这个小模型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
python3 -m pip install --upgrade modelscope

mkdir -p /opt/models/Qwen2.5-0.5B-Instruct

modelscope download \
 --model Qwen/Qwen2.5-0.5B-Instruct \
 --local_dir /opt/models/Qwen2.5-0.5B-Instruct

test -f /opt/models/Qwen2.5-0.5B-Instruct/config.json
test -f /opt/models/Qwen2.5-0.5B-Instruct/model.safetensors
du -sh /opt/models/Qwen2.5-0.5B-Instruct

2. PD 分离

2.1 Connector 选择

Connector 可以理解为 Prefill 和 Decode 之间传递和访问 KV Cache 的具体机制:

  • Prefill 是 KV Cache 的生产者,负责生成并保存 KV Cache;
  • Decode 是 KV Cache 的消费者,负责找到并加载这部分 KV Cache,然后继续生成。

vLLM 提供了多种 KV Connector,用于不同场景下的 KV Cache 传输和管理,例如 NixlConnector、LMCacheConnectorV1、MooncakeConnector 等。

不同版本支持的 Connector 及参数可能发生变化,实际使用时应以对应版本的文档和源码为准。

本文选择 NixlConnector:

  • Prefill 使用 kv_producer 角色生成 KV Cache
  • Decode 使用 kv_consumer 角色通过 NIXL 拉取 KV Cache。

这样可以在不引入额外缓存服务的前提下,直接观察一次跨 vLLM 实例的 KV Cache 传输过程,并从 Decode 日志中确认传输是否成功。

2.2 用 nerdctl 启动单容器和 P/D 进程

简单起见,我们直接使用 nerdctl 启动一个 GPU 容器提供完整的 vLLM 运行环境,再在容器内启动三个相互独立的进程:Prefill、Decode 和 Proxy。

虽然都在一个容器中运行,但是 Prefill 和 Decode 仍然是两个独立的 vLLM 实例,分别监听不同的 HTTP 端口,拥有各自的 KV Cache 管理和 NIXL Connector,它们只是为了简化 Demo,共享同一个容器的网络命名空间和一张 GPU。

先下载与 vLLM v0.27.1 匹配的 toy Proxy。下载到宿主机的工作目录,随后通过目录挂载让容器读取:

1
2
3
4
5
6
7
mkdir -p /tmp/vllm-pd-demo

# toy_proxy_server.py 源码(v0.27.1):
# https://github.com/vllm-project/vllm/blob/v0.27.1/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py
curl -fL --retry 3 --retry-delay 2 \
 "https://raw.githubusercontent.com/vllm-project/vllm/v0.27.1/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py" \
 -o /tmp/vllm-pd-demo/toy_proxy_server.py

创建并启动一个后台容器,三个服务进程稍后分别通过 nerdctl exec 启动:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
nerdctl run -d \
 --name vllm-pd-demo \
 --gpus all \
 --ipc=host \
 --ulimit memlock=-1 \
 --ulimit stack=67108864 \
 -p 8192:8192 \
 -p 8100:8100 \
 -p 8200:8200 \
 --volume /opt/models/Qwen2.5-0.5B-Instruct:/models:ro \
 --volume /tmp/vllm-pd-demo:/work \
 --entrypoint /bin/bash \
 docker.m.daocloud.io/vllm/vllm-openai:v0.27.1 \
 -lc "sleep infinity"

确认容器可以看到 GPU:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
root@lixd-test-gpu:~# nerdctl exec vllm-pd-demo nvidia-smi
Wed Aug 26 03:14:55 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.173.02 Driver Version: 580.173.02 CUDA Version: 13.0 |
+-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 Off | 00000000:00:06.0 Off | 0 |
| N/A 46C P0 29W / 70W | 6847MiB / 15360MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+

接下来打开三个终端。每个终端先执行下面的命令进入同一个容器:

1
nerdctl exec -it vllm-pd-demo bash

终端一:启动 Prefill

在第一个容器终端中执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export VLLM_NIXL_SIDE_CHANNEL_HOST=127.0.0.1
export UCX_NET_DEVICES=all
export UCX_TLS=tcp,cuda_copy,cuda_ipc

VLLM_NIXL_SIDE_CHANNEL_PORT=5600 vllm serve /models \
 --served-model-name=qwen-pd-demo \
 --host=0.0.0.0 \
 --port=8100 \
 --max-model-len=1024 \
 --max-num-seqs=4 \
 --gpu-memory-utilization=0.30 \
 --enforce-eager \
 --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer","kv_load_failure_policy":"fail"}' \
 2>&1 | tee /work/prefill.log

终端二:启动 Decode

在第二个容器终端中执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export VLLM_NIXL_SIDE_CHANNEL_HOST=127.0.0.1
export UCX_NET_DEVICES=all
export UCX_TLS=tcp,cuda_copy,cuda_ipc

VLLM_NIXL_SIDE_CHANNEL_PORT=5601 vllm serve /models \
 --served-model-name=qwen-pd-demo \
 --host=0.0.0.0 \
 --port=8200 \
 --max-model-len=1024 \
 --max-num-seqs=4 \
 --gpu-memory-utilization=0.30 \
 --enforce-eager \
 --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer","kv_load_failure_policy":"fail"}' \
 2>&1 | tee /work/decode.log

这里 Prefill 和 Decode 使用不同的 NIXL side channel 端口 5600 和 5601,避免两个进程在同一个容器的网络命名空间中发生端口冲突。

VLLM_NIXL_SIDE_CHANNEL_HOST 使用 127.0.0.1,是因为两个 vLLM 实例位于同一个容器内。

这两个命令会以前台进程运行,日志会直接显示在各自终端;命令末尾的 tee 还会把日志分别保存到 /work/prefill.log 和 /work/decode.log。

2.3 启动 Proxy 进程

普通的 OpenAI-compatible Proxy 只负责转发请求,不会自动完成 PD 分离。这里使用 vLLM v0.27.1 仓库中与该版本匹配的 toy_proxy_server.py:它会先请求 Prefill,再把 Prefill 返回的 kv_transfer_params 交给 Decode。

在第三个容器终端中执行:

1
2
3
4
5
6
7
8
python3 /work/toy_proxy_server.py \
 --host=0.0.0.0 \
 --port=8192 \
 --prefiller-hosts=127.0.0.1 \
 --prefiller-ports=8100 \
 --decoder-hosts=127.0.0.1 \
 --decoder-ports=8200 \
 2>&1 | tee /work/proxy.log

Proxy 通过 127.0.0.1:8100 和 127.0.0.1:8200 访问同一容器内的 Prefill、Decode,客户端则通过宿主机发布的 8192 端口访问 Proxy。

2.4 验证

Proxy 启动后,可以先通过 /healthcheck 确认 Proxy 已正常启动,并识别到配置的 Prefill 和 Decode 实例:

1
2
root@lixd-test-gpu:~# curl -fsS http://127.0.0.1:8192/healthcheck
{"status":"ok","prefill_instances":1,"decode_instances":1}

确认 Prefill 和 Decode 都完成模型加载后,再发送完整请求。

1
2
curl -fsS http://127.0.0.1:8100/health
curl -fsS http://127.0.0.1:8200/health

发送一次完整请求

不要分别手工请求 Prefill 和 Decode,也不要手工复制 kv_transfer_params。Proxy 会自动完成这几步:

  1. 把请求改写成 max_tokens=1,并发送给 Prefill;
  2. 读取 Prefill 返回的 KV Transfer 元数据;
  3. 恢复客户端原始生成参数,把请求发送给 Decode;
  4. 将 Decode 的结果返回给客户端。

从宿主机通过发布的 Proxy 端口发送请求:

1
2
3
4
5
6
7
8
9
curl -sS --max-time 180 http://127.0.0.1:8192/v1/completions \
 -H "Content-Type: application/json" \
 --data-binary '{
 "model": "qwen-pd-demo",
 "prompt": "Explain the request flow between Prefill and Decode in PD disaggregation.",
 "max_tokens": 8,
 "temperature": 0,
 "stream": false
 }'

成功时会返回 HTTP 200 和一个 completion 响应,通常包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
 "id": "cmpl-...",
 "model": "qwen-pd-demo",
 "choices": [
 {
 "finish_reason": "length"
 }
 ],
 "usage": {
 "prompt_tokens": 15,
 "completion_tokens": 8
 },
 "kv_transfer_params": null
}

最终响应中的 kv_transfer_params 为 null 是正常的,因为它是 Decode 生成并返回给客户端的最终响应。KV Transfer 元数据只在 Proxy 内部从 Prefill 传给 Decode。

检查日志确认 PD 分离

HTTP 200 只能证明接口返回成功,还需要结合 Decode 日志确认 KV Cache 传输。按照本文命令启动时,三个服务的日志既会显示在终端,也会写入 /work。在第四个终端中执行:

1
2
3
nerdctl exec vllm-pd-demo \
 grep -E "NIXL compatibility check passed|KV Transfer metrics|POST /v1/completions" \
 /work/prefill.log /work/decode.log /work/proxy.log

Decode 至少应该出现类似日志:

1
2
3
4
5
NIXL compatibility check passed
KV Transfer metrics: Num successful transfers=1
Avg xfer time (ms)=...
Avg MB per transfer=...
Throughput (MB/s)=...

其中 Decode 的 Num successful transfers=1 是判断 KV Cache 确实被拉取的关键证据。Prefill 和 Decode 的请求日志应该都返回 200 OK,Proxy 日志应该出现 POST /v1/completions ... 200 OK。NIXL compatibility check passed 只能说明两端配置兼容,不能单独证明本次请求已经完成 KV Cache 传输。

一次已验证的运行记录

下面是完成一次推理请求后,从三个日志文件中筛选出的关键日志。查询命令的输出包含多次请求记录,下面保留与这次验证相关的关键行:

1
2
3
4
5
/work/prefill.log:(APIServer pid=40) INFO: 127.0.0.1:45150 - "POST /v1/completions HTTP/1.1" 200 OK
/work/decode.log:(EngineCore pid=198) INFO 08-25 07:35:17 [base_worker.py:680] NIXL compatibility check passed (hash: b5f080ccb62b60a662e683a3e38305f1609bc636242f37736e2a8bba912cba59)
/work/decode.log:(APIServer pid=52) INFO 08-25 07:50:17 [metrics.py:103] KV Transfer metrics: Num successful transfers=1, Avg xfer time (ms)=1.51, P90 xfer time (ms)=1.51, Avg post time (ms)=1.51, P90 post time (ms)=1.51, Avg MB per transfer=0.188, Throughput (MB/s)=124.172, Avg number of descriptors=24.0
/work/decode.log:(APIServer pid=52) INFO: 127.0.0.1:58494 - "POST /v1/completions HTTP/1.1" 200 OK
/work/proxy.log:INFO: 10.4.0.1:57586 - "POST /v1/completions HTTP/1.1" 200 OK

其中 KV Transfer metrics 的 Num successful transfers=1 是本次验证最关键的证据,表示 Decode 侧至少完成了一次 NIXL KV Cache 传输。Avg MB per transfer=0.188 表示本次平均传输了约 0.188 MB 的 KV Cache。Prefill、Decode 和 Proxy 的请求都返回了 200 OK,说明请求链路也完整走通。

清理

停止并删除 Demo 容器:

1
nerdctl rm -f vllm-pd-demo

3. PD 分离的请求执行流程

前面的验证确认了请求确实经过了 Prefill、Decode。下面把一次请求拆开,分别看控制请求和 KV Cache 数据是如何流动的。

一次请求的执行流程如下:

vLLM PD 分离应用的请求流程

3.1 Client -> Proxy:请求进入编排层

客户端只需要发送一次普通的 OpenAI-compatible Completion 请求,例如设置 max_tokens=1024。客户端不需要知道 Prefill、Decode 的地址,也不需要自己构造 kv_transfer_params。

Proxy 为请求生成 request ID,同时保留客户端原始生成参数。发送给 Prefill 的请求会基于原始请求进行改写,后续再使用原始参数构造 Decode 请求。

3.2 Proxy -> Prefill:只做 Prefill

Proxy 将请求发送给 Prefill 时,会把 max_tokens 改为 1,同时附加类似下面的 kv_transfer_params:

1
2
3
4
5
6
{
 "kv_transfer_params": {
 "do_remote_decode": true,
 "do_remote_prefill": false
 }
}

这表示当前实例负责 Prefill,后续 Decode 由远端实例完成。Prefill 处理完整 Prompt,生成对应的 KV Cache。由于请求被设置为 max_tokens=1,Prefill 还会产生一个单 Token 响应,同时返回 KV Transfer 元数据。

这个 Token 后续会被 Proxy 丢弃。

这些元数据包含 Prefill 侧的 Engine ID、请求 ID、KV block ID,以及 NIXL side channel 的地址和端口等信息,它们是下一阶段 Decode 找到 KV Cache 的定位信息。在本文单容器 Demo 中,实例地址使用 127.0.0.1;实际多容器或多节点部署时,应替换成 Decode 可以访问的地址。

3.3 Prefill -> Proxy -> Decode:交接 KV Cache

Proxy 会把 Prefill 返回的第一个 Token 直接丢弃,然后取出其中的 kv_transfer_params,覆盖到原始请求上,并恢复客户端要求的 max_tokens。

发送给 Decode 的请求大致包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
 "max_tokens": 8,
 "kv_transfer_params": {
 "do_remote_prefill": true,
 "do_remote_decode": false,
 "remote_engine_id": "...",
 "remote_request_id": "...",
 "remote_block_ids": [[1]],
 "remote_host": "127.0.0.1",
 "remote_port": "<NIXL side-channel port>"
 }
}

这里有两条不同的链路:

  • 控制请求通过 Proxy 的 HTTP 端口到达 Prefill 和 Decode;
  • KV Cache 不经过 Proxy,Decode 根据元数据通过 NIXL 直接从 Prefill 读取。

因此,Proxy 负责请求编排,Connector/NIXL 负责 KV Cache 传输,两者是不同的职责。单容器只让 HTTP 地址和 NIXL 地址都表现为回环地址,并没有把两个 vLLM 实例合并成一个 Engine。

3.4 Decode -> Proxy -> Client:继续生成并返回结果

Decode 收到请求后,根据 remote_engine_id、remote_request_id、remote_block_ids 等信息,通过 NIXL 拉取 Prefill 生成的 KV Cache。KV Cache 加载完成后,Decode 不再重复计算完整 Prompt,而是从已有上下文继续逐 Token 生成。

最终返回给客户端的是 Decode 的响应,Prefill 的临时 Token 不会出现在最终结果中。Decode 日志中的 NIXL compatibility check passed 和 Num successful transfers=1,分别说明传输能力协商成功,以及这次请求确实完成了 KV Cache 传输。

4. PD 分离在生产环境中的挑战

前面的 Demo 有意把环境压到了最小:只有一个 Prefill、一个 Decode,而且两个实例还运行在同一个容器和同一张 GPU 上。这样很适合观察请求和 KV Cache 的流向,但真正到了多实例、跨节点环境后,很多问题才会出现。

在真正的生产环境中,PD 分离还需要解决很多更复杂的问题,例如:

  • KV Cache 管理:不仅要解决 KV Cache 如何从 Prefill 传输到 Decode,还要考虑缓存的生命周期、释放与回收、传输超时与失败重试,以及跨节点场景下的网络带宽和传输开销。
  • P/D 实例调度:生产环境通常会同时运行多个 Prefill 和 Decode 实例。Router 需要根据实例负载、KV Cache/Prefix Cache 命中情况等信息选择合适的 P、D 实例,而不是简单地做一次请求转发。
  • P/D 容量配比与扩缩容:Prefill 和 Decode 的资源特征不同,Prompt 长度、输出长度以及请求并发度变化后,两侧的压力也会发生变化,因此需要分别进行容量规划、负载均衡和弹性扩缩容。
  • 故障处理:Prefill 成功但 Decode 失败、KV Cache 传输异常、请求超时或取消等情况,都需要处理请求重试、状态清理以及 KV Cache 回收等问题。
  • 性能与可观测性:PD 分离并不会凭空提高吞吐。它主要解决的是 Prefill 和 Decode 相互干扰的问题,让 TTFT 和 ITL 可以分别优化,特别是改善 tail ITL。代价也很直接:多了一层请求调度和 KV Cache 传输。生产环境还需要持续关注 P/D 排队时间、KV Cache 传输耗时、传输带宽和失败情况,判断拆分带来的收益是否真正大于额外成本。

这篇文章先把 PD 分离最基础的链路跑通。到了多实例、跨节点环境后,关注点就会从“KV Cache 能不能传过去”,转向实例怎么调度、缓存怎么管理、失败怎么处理,以及拆分之后到底能不能改善实际的延迟指标。

5. 小结

本文通过一个基于 vLLM、NixlConnector 和 toy Proxy 的最小 Demo,把 PD 分离之后的请求执行流程和 KV Cache 数据流串了起来。

PD 分离之后,请求流程如下:

  1. Client 将原始请求发送给 API Proxy。
  2. API Proxy 将请求改写为 max_tokens=1,并发送给 Prefill 实例。
  3. Prefill 计算完整 Prompt,生成 KV Cache,同时返回一个临时 Token 和 KV Transfer 元数据。
  4. API Proxy 丢弃 Prefill 返回的临时 Token,只保留 KV Transfer 元数据。
  5. API Proxy 将原始请求再次发送给 Decode,并携带 kv_transfer_params,恢复原始的 max_tokens=N。
  6. Decode 根据传输元数据,通过 NIXL 直接从 Prefill 拉取 KV Cache,这部分数据不经过 API Proxy。
  7. Decode 基于已经加载的 KV Cache 继续生成完整 Token,并将最终结果返回给 API Proxy。
  8. API Proxy 将 Decode 的最终结果返回给 Client。

其中,API Proxy 负责请求编排,Prefill 负责 Prompt 计算和 KV Cache 生成,Decode 负责拉取 KV Cache 并继续生成。一个容器降低了环境准备和网络配置的复杂度,但 Prefill、Decode 仍然是两个独立的 vLLM 实例,NixlConnector 仍然负责两者之间的 KV Cache 传输。

KubeClipper 1.7.0 发布:Operation 优化与 Kubernetes 1.37 支持

2026-09-08 04:00:00

kubeclipper-release-1.7.0.jpg

KubeClipper 1.7.0 发布了。

本次版本新增 API 驱动的 Operation v2,移除了旧的 NATS Operation 投递链路,集群创建、扩容、删除等操作统一使用新的 Operation v2。新增 kcctl status 和 kcctl doctor 平台诊断命令,新增了对 Kubernetes 1.37 版本的支持,并对镜像仓库管理、离线镜像加载和部署预检查进行了优化。

KubeClipper 是一个轻量便捷的 Kubernetes 多集群全生命周期管理工具,旨在提供易使用、易运维、极轻量、生产级的 Kubernetes 多集群管理服务,让运维工程师从繁复的配置和晦涩的命令行中解放出来,实现一站式管理跨区域、跨基础设施的多 K8S 集群。

🚀 5分钟快速体验

第一次用 KubeClipper,可以先按下面这几步走一遍:

  1. 安装工具:curl -sfLk https://oss.kubeclipper.io/get-kubeclipper.sh | bash -
  2. 部署服务:kcctl deploy
  3. 创建集群:kcctl create cluster --name demo --master YOUR_IP --untaint-master
  4. 打开控制台:访问 http://YOUR_IP,默认账号 admin/Thinkbig1

四步,从零开始创建一个 Kubernetes 集群。部署完成后,还可以使用 kcctl status 和 kcctl doctor 检查平台状态。

1. KubeClipper 1.7.0 新特性详解

1.7.0 的主要变化集中在以下几个方面:Operation v2、平台诊断、镜像仓库管理和部署稳定性。

本次更新的主要亮点:

  • 🆕 Operation v2:使用 API 和 etcd 管理集群操作,支持任务状态查询、重试、取消和重启恢复
  • 🩺 平台诊断命令:新增 kcctl status 和 kcctl doctor
  • 🔄 Kubernetes 1.37 支持:默认版本升级至 v1.37.0
  • 📦 Registry 与离线镜像:支持选择 Registry 资源,离线模式支持加载节点本地镜像
  • 🔧 稳定性提升:增加部署预检查,修复传输校验、操作清理和 Agent 运行等问题

1.1 Kubernetes 1.37 与组件升级

1.7.0 支持的版本如下:

Kubernetes Calico Containerd
v1.37.0(默认) v3.31.5 v2.2.4
v1.36.4 v3.31.5 v2.2.4
v1.35.8 v3.31.5 v2.2.4

1.6.0 的默认 Kubernetes 版本为 v1.36.1,1.7.0 已升级至 v1.37.0,v1.35 和 v1.36 也同步更新到最新维护版本。Calico 和 Containerd 继续使用 v3.31.5 和 v2.2.4。

1.2 Operation v2,集群操作更加可靠

1.7.0 新增 API 驱动的 Operation v2,Operation 和 OperationTask 作为 API 资源保存到 etcd,支持查看任务状态、重试、取消、超时处理和重启恢复,集群创建、扩缩容、删除、备份恢复等操作都统一使用这套机制。

同时修复了 Operation 状态持久化、集群状态并发更新冲突、操作历史清理和删除校验 panic 等问题,并优化了关键状态读取和事件分发效率。

1.3 kcctl status 和 kcctl doctor

1.7.0 新增了平台状态查看和诊断命令:

1
2
kcctl status
kcctl doctor

kcctl status 用于查看 kc-server、kc-etcd 和 kc-agent 的整体健康状态;kcctl doctor 用于进一步检查平台配置和服务状态,在异常时给出排查信息。doctor 只做诊断,不会自动修改或重启服务。

1.4 Registry 管理与离线镜像支持

1.7.0 将 Cluster.imageRegistry 调整为引用已配置的 Registry 资源,创建或升级集群时直接传入资源名称即可。KubeClipper 会自动读取 Registry 的地址、协议、认证和证书配置,并用于 Kubernetes、Containerd、Calico 等组件的镜像。

旧的 image repository/local registry 字段和 CLI 别名已移除。同时修复了 HTTP Registry 协议丢失的问题,离线模式且未指定 Registry 时,会直接加载离线安装包中随包分发到节点的镜像;在线模式未指定 Registry 时,各组件继续使用自己的默认镜像仓库。

1.5 部署与运行稳定性提升

这一版包含多项部署和运行稳定性修复:

  • 部署预检查:新增服务端口占用检查,预检查异常会明确显示节点 IP 和角色;部署 kc-server 前会等待 etcd Endpoint 通过健康检查。
  • 部署配置:支持通过 --temp-dir 指定安装包暂存目录
  • 运行稳定性:修复操作历史清理、Agent 命令输出缓冲、子进程管道阻塞和 watch 事件恢复等问题

2. 安装部署 KubeClipper 1.7.0

2.1 环境要求

  • CPU:2 核及以上
  • 内存:4GB 及以上
  • 操作系统:推荐 Ubuntu 22.04 / Ubuntu 24.04
  • 网络:节点间网络互通,并可通过 SSH 连接
  • 基础工具:sudo / curl / wget / tar
  • 架构:amd64 / arm64

主机最好保持相对干净,避免已有容器运行时或系统配置与安装过程产生冲突。

2.2 安装 kcctl 工具

1
2
3
4
5
# 下载最新版本的 kcctl
curl -sfLk https://oss.kubeclipper.io/get-kubeclipper.sh | bash -

# 验证安装
kcctl version

如果需要安装指定版本,可以通过 KC_VERSION 指定:

1
2
curl -sfLk https://oss.kubeclipper.io/get-kubeclipper.sh | \
 KC_VERSION=v1.7.0 bash -

2.3 部署 KubeClipper

1
2
3
4
5
6
7
# 对于 AIO 环境使用 kcctl deploy 即可完成部署
kcctl deploy

# 更多参数参考 kcctl deploy -h
# kcctl deploy --server $IPADDR_SERVER --agent $IPADDR_AGENT \
# --pk-file /root/.ssh/id_rsa --pkg $PKG \
# --ip-detect=interface=ens3 --temp-dir /data/kc-tmp --v 5

看到下面的 banner 就说明部署完成了:

1
2
3
4
5
6
7
8
 _ __ _ _____ _ _
| | / / | | / __ \ (_)
| |/ / _ _| |__ ___| / \/ |_ _ __ _ __ ___ _ __
| \| | | | '_ \ / _ \ | | | | '_ \| '_ \ / _ \ '__|
| |\ \ |_| | |_) | __/ \__/\ | |_) | |_) | __/ |
\_| \_/\__,_|_.__/ \___|\____/_|_| .__/| .__/ \___|_|
 | | | |
 |_| |_|

安装过程中需要下载离线安装包,具体耗时取决于网络速度。

2.4 访问 Web UI

安装完成后,打开浏览器,访问 http://$IP 即可进入 KubeClipper 控制台。

kc-console-login.jpg

您可以使用默认帐号密码 admin / Thinkbig1 进行登录。

3. 快速上手体验

3.1 创建 K8s 集群

部署成功后可以使用 kcctl 工具或者通过控制台创建 K8s 集群,这里使用 kcctl 工具进行创建。

查看当前 Agent 节点:

1
kcctl get node

创建集群:

1
kcctl create cluster --name demo --master YOUR_IP --untaint-master

1.7.0 默认使用 Kubernetes v1.37.0、Calico v3.31.5 和 Containerd v2.2.4。创建过程中可以查看实时操作:

1
2
kcctl operation list -c demo
kcctl operation logs -c demo

如果操作失败,可以查看详情、重试或取消:

1
2
3
kcctl operation describe <OPERATION_ID>
kcctl operation retry <OPERATION_ID>
kcctl operation cancel <OPERATION_ID>

集群进入 Running 状态后,用 kubectl 查看集群健康状况:

1
2
kubectl get node -owide
kubectl get pods -A

几条命令,一个单节点 K8s 集群就创建完成了。

3.2 平台状态检查

部署和集群创建完成后,可以使用下面的命令检查 KubeClipper 平台状态:

1
2
kcctl status
kcctl doctor

kcctl status 用于查看平台整体健康状态,kcctl doctor 用于进一步定位配置、服务和节点问题。

3.3 KubeClipper 的工作负载管理功能

KubeClipper 的工作负载管理功能在 1.7.0 中仍然可以直接通过 Web UI 管理 Deployment、StatefulSet 等 Kubernetes 工作负载。

kc-console-workload3.png

4. 小结

这一版主要干了三件事:升级集群操作机制、补充平台诊断命令、优化镜像和部署流程。

组件升级:

  • ✅ Kubernetes 1.37: 支持 v1.37.0、v1.36.4 和 v1.35.8
  • ✅ Containerd v2.2.4: 延续 1.6.0 的默认版本
  • ✅ Calico v3.31.5: 延续 1.6.0 的默认版本

操作与诊断:

  • ✅ Operation v2: 使用 API 和 etcd 管理集群操作
  • ✅ kcctl status: 查看平台整体健康状态
  • ✅ kcctl doctor: 提供平台故障诊断信息

稳定性提升:

  • ✅ Registry 管理: 使用 Registry 资源配置集群镜像仓库
  • ✅ 离线镜像: 离线模式未指定 Registry 时支持加载离线安装包中的本地镜像
  • ✅ 部署优化: 增加端口预检查和 etcd 健康检查,完善节点错误信息和传输失败处理
  • ✅ 运行修复: 修复操作清理、Agent Worker 和 watch 事件相关问题

PD 分离详解:Prefill 与 Decode 的瓶颈、拆分与代价

2026-09-03 04:00:00

为什么要 PD 分离

LLM 推理服务的性能瓶颈,不只在模型大小和 GPU 算力。副本增加后,请求落到哪个实例、长 Prompt 是否干扰流式输出、KV Cache 能否复用,都会直接影响延迟、吞吐和成本。

其中,Prefill/Decode 分离(PD 分离)是把两类截然不同的计算负载拆到独立资源池中的方案。本文从问题出发,说明它解决什么、代价是什么,以及何时不该使用它。

推理服务真的无状态吗?

先问大家一个问题:推理服务是无状态的吗?

是,也不是。

只要客户端每次请求都带上完整的对话上下文,把请求调度到哪个后端服务,通常都不会影响推理的语义正确性。模型看到的上下文是完整的。

至于采样带来的输出差异,和请求落在哪个副本上是两回事。

但平台看到的是另一层状态。每个实例都维护着自己的请求队列、Prefix Cache 和 KV Cache。随机或者轮询不会破坏结果的正确性,却可能让请求反复落到没有对应缓存的实例上,造成 Cache Miss、重复 Prefill 和更多 GPU 计算,最终表现为更高的 TTFT、更低的吞吐和更高的资源成本。

同一段系统提示词、长文档或者多轮对话前缀,可能已经在实例 A 上完成 Prefill,并进入 Prefix Cache。下一次请求如果仍然落到实例 A,就有机会复用计算结果;如果被分给实例 B,实例 A 上的缓存没有损坏或失效,只是实例 B 无法使用,只能重新执行 Prefill。

即:LLM 推理服务在用户层面是无状态的,但在运行时层面却不是。

如果只关心接口是否可用、推理结果是否正确,可以把 LLM 推理服务看成无状态服务;但只要开始关注延迟、吞吐和 GPU 成本,就不能再忽略实例上的运行时状态。

普通负载均衡不懂 LLM 的运行时状态。它可能知道哪些实例 Ready,可以完成基础的流量分配,却不知道哪个实例的队列已经堆满、KV Cache 还剩多少,也不知道当前请求的前缀曾经在哪个实例上计算过。

所以,多副本 LLM 服务需要的不只是负载均衡,而是能够感知负载和缓存局部性的智能路由。

为什么要拆分 Prefill 和 Decode?

Prefill 与 Decode 的计算特征不同

一次 LLM 推理请求可以粗略拆成 Prefill 和 Decode 两个阶段:

  • Prefill 阶段处理输入 Prompt,并生成后续 Decode 所需的 KV Cache。Prompt 越长,需要完成的计算越多,因此这一阶段通常是 compute-bound,主要受 GPU 计算能力限制。

  • Decode 利用已有 KV Cache,一次生成一个 Token。生成过程中,Attention 需要持续读取此前 Token 对应的 KV Cache;上下文越长,需要读取的历史 KV 越多,因此这一阶段通常是 memory-bandwidth-bound,更依赖显存带宽与 KV Cache 容量。

性能指标与资源差异

两个阶段对应的性能指标也不同:

  • Prefill 侧主要关注 TTFT(Time To First Token):也就是从请求发出到收到第一个 Token 的时间。它不仅包含 Prefill 计算,还包括此前的排队和路由等开销。

  • Decode 侧主要关注 TPOT(Time Per Output Token) 和 ITL(Inter-Token Latency):

    • TPOT 表示第一个 Token 之后,平均生成一个输出 Token 所需的时间;
    • ITL 表示相邻两个输出 Token 之间的时间,更能反映流式输出是否稳定、有没有明显抖动。

在 Long Context 场景下,Prefill 和 Decode 对计算、显存带宽与容量的需求更加不平衡。

如果把它们拆开,Prefill Pool 可以偏向计算能力更强的 GPU,Decode Pool 则可以偏向显存带宽更高、容量更大的 GPU。

两边的 Batch 策略也不同。Prefill 的 Batch 增大到一定程度后,计算单元会逐渐饱和,继续增加 Batch 对吞吐的提升趋缓,还可能推高 TTFT,因此通常需要控制 Batch Size。Decode 每一步只为每条活跃序列生成一个 Token,适当增大 Batch 可以提高 GPU 利用率与整体输出吞吐,但仍然要受 TPOT、ITL 和 KV Cache 容量约束。

不同 Batch Size 和输入长度下 Prefill、Decode 两个阶段的吞吐变化

图源:《DistServe: Disaggregating Prefill and Decoding for Goodput-Optimized Large Language Model Serving》,Figure 3。展示了模型在不同 Batch Size 与输入长度下的测试结果。具体数值取决于模型、硬件和推理引擎,这里主要观察两条趋势:Prefill 吞吐较早趋于平缓,而 Decode 吞吐可以随 Batch Size 增大继续提升。

在同一设备上同时运行 Prefill 和 Decode,意味着这颗芯片必须同时具备强大的算力(满足 Prefill 需求)和巨大的显存容量/带宽(满足 Decode 的 KV Cache 需求)。

然而,受限于芯片面积、功耗、良率和成本,芯片设计需要在"计算单元"和"显存接口/HBM 容量"之间做面积与功耗的 trade-off。因此,一颗"通吃"两颗阶段需求的芯片必然成本高昂,且在任一单一阶段都难以达到最优性价比。

PD 分离的本质,就是承认这种硬件层面的 trade-off,让 Prefill 实例和 Decode 实例各自采用最适合自己工作负载特征的硬件配置(如 Prefill 用高算力卡,Decode 用高 HBM 容量卡),从而在系统层面实现总体拥有成本(TCO)的最优。

共置部署为什么会互相干扰

即使每次都选对了实例,也只解决了路由问题。Prefill 和 Decode 如果继续放在同一个实例里,就会共享 GPU 和调度队列。长 Prompt 带来的 Prefill 峰值,可能抬高其他请求的 Token 间延迟。

这就是执行层的问题:怎样减少 Prefill 和 Decode 共享 GPU、共享调度队列带来的相互干扰。对应的解决思路,就是进一步把 Prefill 和 Decode 拆到不同实例,也就是 PD 分离。

PD 分离到底拆了什么?

PD 分离(Prefill/Decode Disaggregation) 将 LLM 推理的两个阶段解耦,部署到独立的 Prefill 实例组和 Decode 实例组:

  • Prefill 实例组接收完整输入 Prompt,构建本次请求所需的 KV Cache,并生成首个输出 Token;
  • Decode 实例组通过高速网络获取 Prefill 产出的 KV Cache,以首 Token 为起点进行自回归解码,逐 Token 生成直至请求结束;

两个阶段通过高效的 KV Cache 传输机制衔接,并可以独立扩缩容,从而在合适的资源配比和传输网络下,同时优化吞吐量与延迟。

聚合式推理与 PD 分离对比

这里拆分的是执行位置和资源池,不是模型的计算顺序。一次请求仍然要先完成 Prefill,才能进入 Decode,只是两个阶段不再运行在同一个实例里。

因此,一次请求会像接力一样经过两组实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
请求进入
 │
 ▼
Prefill Pool:处理 Prompt,生成 KV Cache 和首 Token
 │
 ▼
Decode Pool:取得 KV Cache,继续生成后续 Token
 │
 ▼
返回响应

拆到这里,一个新的问题也随之出现:Prefill 和 Decode 不在同一个实例,KV Cache 怎么交接?

KV Cache 可以理解为模型处理 Prompt 后留下的中间状态。Decode 要从 Prefill 结束的位置继续生成,就必须获得本次请求对应的 KV Cache(否则就需要重新计算)。

聚合式推理中,两个阶段在同一个实例内运行,Decode 可以直接继续使用 Prefill 已经构建的 KV Cache。PD 分离后,两个阶段位于不同实例,各自管理本地显存,因此必须增加一次显式的交接:

  1. Prefill 完成计算,将 KV Cache 保留在本地,同时向请求编排组件返回 KV Transfer 元数据。
  2. 编排组件携带这些元数据发起 Decode 请求。
  3. Decode 根据元数据,通过 NIXL 等 KV Connector 从 Prefill 实例获取 KV Cache,然后继续逐 Token 生成。

这里有两条不同的链路:编排组件传递的是如何取得 KV Cache 的元数据;真正的 KV Cache 数据不经过 Router / Proxy。至于数据是在 Prefill 和 Decode 之间 P2P 传输,还是经由共享存储或缓存服务交接,则取决于具体使用的 Connector。

这也不等于 Prefix Cache 命中。

  • Prefix Cache 复用解决的是 Prefill 能否利用以前计算过的相同前缀,从而少做一部分计算;
  • KV Cache 传输解决的是本次 Prefill 的结果如何交给 Decode。即使没有命中 Prefix Cache,Prefill 也可以完整处理 Prompt,再把新生成的 KV Cache 传给 Decode。

KV Cache 共享方式有哪些?

在 vLLM 中,KV Cache 的交接由 KV Connector 抽象。它让作为 KV producer 的 Prefill 实例,将缓存交给作为 KV consumer 的 Decode 实例;调度器侧的 Connector 负责安排传输,Worker 侧的 Connector 负责实际读写数据。

不同 Connector 的差异,主要在于 KV Cache 放在哪里,以及 Prefill 和 Decode 之间通过什么方式传输:

  • P2P 传输:NixlConnector 通过 NIXL 在实例之间传输 KV Cache,适合高速网络和异步收发场景。
  • 共享存储或缓存服务:ExampleConnector 使用本地共享目录,主要用于验证 Connector 接口和 PD 分离流程;LMCacheConnectorV1 由 LMCache 管理 KV Cache,可使用 NIXL 作为底层传输,也支持独立的 LMCache Server;MooncakeConnector 与 FlexKVConnectorV1 则分别使用 Mooncake 和 FlexKV 作为 KV Cache 的传输、管理或分布式存储层。
  • 卸载、多级缓存与特定硬件:OffloadingConnector 将 KV Cache 转移到 CPU 内存或文件系统等更低层级的存储中;MultiConnector 按顺序组合多个 Connector,形成多级 KV Cache 路径;MoRIIOConnector 面向 ROCm 环境。

vLLM 通过 KVTransferConfig 选择 Connector,并传递具体实现需要的参数。例如 NIXL Connector 的配置可以写成:

1
2
--kv-transfer-config \
'{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'

Connector 解决的是 KV Cache 如何被生产、保存和消费,不负责决定请求应该选择哪个 Prefill 或 Decode 实例。后者属于路由和请求编排问题,两者可以组合使用,但不是同一层能力。

PD 分离的收益与代价

PD 分离之后,最直接的收益是隔离两种计算负载。 vLLM 官方文档特别强调了 tail ITL:

  • 聚合模式会在 Decode 过程中插入 Prefill 工作,导致部分 Token 的生成间隔突然升高;
  • PD 分离以后,长 Prompt 留在 Prefill Pool,不再直接占用 Decode Pool 的调度队列,逐 Token 输出会更稳定。

Prefill 与 Decode 还可以独立设置副本数、GPU 类型和并行策略:

  • 文档问答通常输入很长、输出较短,可能需要更多 Prefill 能力;
  • 聊天或推理场景输出很长,则可能更需要 Decode 能力。 通常可以用 xP:yD 表示两组实例的比例,具体取值要根据真实的输入长度、输出长度、并发和延迟目标压测出来。

PD 分离也不是减少阶段干扰的唯一方案。

vLLM 默认支持 Chunked Prefill,它会把较长的 Prefill 拆成较小的 Chunk,优先调度 Decode,再利用剩余的 Token Budget 插入 Prefill。这样可以在同一实例中平衡 compute-bound 的 Prefill 和 memory-bound 的 Decode,部署与调度也更简单; 但两个阶段仍然共享 GPU,max_num_batched_tokens 的取值也需要在 TTFT、ITL 和吞吐之间权衡。只有当这种共置优化仍无法同时满足两个阶段的 SLO,或者两边需要完全不同的资源配置和扩缩容策略时,PD 分离的价值才会更加明显。

当然,PD 分离也不是银弹。

  • 模型权重要在 Prefill 和 Decode 两组 GPU 上分别加载
  • 一次请求多了 Endpoint 选择和阶段编排
  • KV Cache 还要跨实例传输
  • 还需要额外考虑 NIXL、RDMA、故障恢复和两组工作负载的独立扩缩容。

如果模型很小、Prompt 很短,或者网络只能走普通 TCP,这些额外开销可能超过隔离带来的收益。

因此,单 GPU vLLM 不会因为换成 PD 架构就自动变快,vLLM 也明确提醒,Disaggregated Prefilling 本身并不保证提高吞吐。 只有当 Prefill 对 Decode 的干扰已经影响 TTFT 或 ITL,或者两边的资源需求明显不对称时,PD 分离才值得引入。

小结

PD 分离的核心原因,是 Prefill 和 Decode 的瓶颈并不相同:

  • Prefill 通常是 compute-bound,更依赖 GPU 算力;
  • Decode 则通常是 memory-bandwidth-bound,更依赖显存带宽和 KV Cache 容量。

把两个阶段放在同一个实例里,就要求同一套硬件同时满足两种不同的资源需求。

这不表示不存在同时拥有强算力和大 HBM 的高端芯片。问题在于,增加计算单元不一定能改善 Decode,堆叠更大的 HBM 和更宽的显存接口又会带来额外的面积、功耗和成本。硬件本身就在算力与内存系统之间做取舍,不存在能在所有输入输出比例、延迟目标和成本约束下都对两个阶段最优的芯片配置。

因此,PD 分离不是把一次推理变成两次推理,而是把同一次推理中的 Prefill 与 Decode 放到各自合适的资源池:Prefill 可以使用偏算力的硬件,Decode 可以使用偏显存容量和带宽的硬件,再通过 KV Cache 交接把两个阶段衔接起来。

当然,PD 分离不是银弹,这套架构也需要额外的调度、网络和容量规划。先用 Chunked Prefill 等共置优化解决问题;只有 TTFT、ITL 或资源配比已经成为明确瓶颈时,再引入 PD 分离。

KServe 集成 KEDA:基于 vLLM 指标自动扩缩容

2026-08-27 04:00:00

KServe 集成 KEDA:基于 vLLM 指标自动扩缩容

模型服务运行后,固定副本数很难同时兼顾突发请求和资源利用率。请求增加时需要扩容,流量回落后又希望及时缩容。

本文通过一个简单 Demo,为 KServe 模型服务接入 KEDA,根据 vLLM 的请求指标自动调整副本数,并验证服务从 1 -> 2 -> 1 的扩缩容过程。

环境准备

实现思路

需要在额外安装 KEDA 和 Prometheus,工作流程如下:

  1. KServe 创建 Predictor Deployment 启动模型服务,底层推理引擎 vLLM 通过 /metrics 暴露运行指标。
  2. Prometheus 采集这些指标,例如 vllm:num_requests_running 和 vllm:num_requests_waiting。
  3. KEDA 查询 Prometheus,并通过 External Metrics API 将查询结果暴露为 HPA 可以使用的外部指标。
  4. HPA 比较指标当前值与目标值,计算期望副本数并调整 Predictor Deployment。

KServe 集成 KEDA 的自动扩缩容链路

安装 KEDA

本文实测 KEDA 2.17.2:

1
2
3
4
5
6
7
helm repo add kedacore https://kedacore.github.io/charts
helm repo update

helm upgrade --install keda kedacore/keda \
 -n keda --create-namespace \
 --version 2.17.2 \
 --wait

安装后确认 Operator、Admission Webhook 和 Metrics API Server 都已启动:

1
2
kubectl get pod -n keda
kubectl get apiservice v1beta1.external.metrics.k8s.io
1
2
3
4
5
6
keda-admission-webhooks-... 1/1 Running
keda-operator-... 1/1 Running
keda-operator-metrics-apiserver-... 1/1 Running

NAME SERVICE AVAILABLE
v1beta1.external.metrics.k8s.io keda/keda-metrics-apiserver True

只有 CRD 不够。external.metrics.k8s.io 不可用时,ScaledObject 可以创建,但 HPA 读不到 KEDA 提供的指标。

部署 Prometheus

本文使用 kube-prometheus-stack 部署 Prometheus 和 Prometheus Operator,Grafana、Alertmanager、Exporter 和默认告警规则都关闭,只保留指标采集需要的组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
helm upgrade --install prometheus \
 oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack \
 -n monitoring --create-namespace \
 --version 88.2.0 \
 --set grafana.enabled=false \
 --set alertmanager.enabled=false \
 --set kubeStateMetrics.enabled=false \
 --set nodeExporter.enabled=false \
 --set defaultRules.create=false \
 --set kubeApiServer.enabled=false \
 --set kubelet.enabled=false \
 --set kubeControllerManager.enabled=false \
 --set coreDns.enabled=false \
 --set kubeDns.enabled=false \
 --set kubeEtcd.enabled=false \
 --set kubeScheduler.enabled=false \
 --set kubeProxy.enabled=false \
 --set prometheusOperator.admissionWebhooks.enabled=false \
 --set prometheusOperator.tls.enabled=false \
 --wait

先确认 Prometheus Service 已经创建。下面的 InferenceService 和后续查询都使用这个 Service;如果命令没有输出,应先检查 Helm Release 和 Prometheus Pod,不要继续创建 InferenceService。

1
2
kubectl get svc prometheus-kube-prometheus-prometheus -n monitoring
kubectl get pod -n monitoring

当前配置不创建 PrometheusRule,因此一并关闭了只用于校验规则的 Admission Webhook。安装完成后,集群中应保留 Prometheus Operator 和 Prometheus 两个 Pod:

1
kubectl get pod,svc -n monitoring

配置自动扩缩容

创建 InferenceService

当前测试节点只有一张 GPU,而 Demo 需要把服务扩到两个副本,因此继续使用 HAMi DRA 共享 GPU。

创建 ResourceClaimTemplate 和完整的 InferenceService,同时配置每个副本的 GPU 配额、模型和 KEDA 自动扩缩容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
cat <<'EOF' > qwen-llm.yaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
 name: qwen-hami-gpu
 namespace: kserve-test
spec:
 spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: hami-core-gpu.project-hami.io
 allocationMode: ExactCount
 count: 1
 capacity:
 requests:
 memory: 3Gi
 cores: "20"
---
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
 annotations:
 serving.kserve.io/deploymentMode: Standard
 serving.kserve.io/autoscalerClass: keda
spec:
 predictor:
 minReplicas: 1
 maxReplicas: 2
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 autoScaling:
 metrics:
 - type: External
 external:
 metric:
 backend: prometheus
 serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc:9090
 query: >-
 sum(vllm:num_requests_running{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 +
 sum(vllm:num_requests_waiting{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 target:
 type: Value
 value: "1"
 model:
 modelFormat:
 name: huggingface
 image: docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
 resources:
 requests:
 cpu: "1"
 memory: 4Gi
 limits:
 cpu: "2"
 memory: 6Gi
 claims:
 - name: gpu
EOF

kubectl apply -f qwen-llm.yaml

kubectl wait --for=condition=Ready \
 inferenceservice/qwen-llm -n kserve-test --timeout=10m

每个 Predictor 副本通过 qwen-hami-gpu 申请 3Gi 显存和 20% GPU 核心,因此单 GPU 节点可以同时运行两个副本。
KServe 创建 Predictor Deployment 和 ScaledObject,KEDA 再创建 HPA 管理副本数。

扩缩容配置解析

这份 YAML 中扩缩容配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
metadata:
 annotations:
 serving.kserve.io/autoscalerClass: keda
spec:
 predictor:
 minReplicas: 1
 maxReplicas: 2
 autoScaling:
 metrics:
 - type: External
 external:
 metric:
 backend: prometheus
 serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc:9090
 query: >-
 sum(vllm:num_requests_running{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 +
 sum(vllm:num_requests_waiting{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 target:
 type: Value
 value: "1"

各参数含义:

  • autoscalerClass: keda:使用 KEDA 进行扩缩容
  • minReplicas、maxReplicas:限制最小、最大副本数
  • serverAddress:Prometheus 地址
  • query:使用 running + waiting,同时统计正在执行和已经进入 vLLM 队列的请求
  • target.value:每个副本期望承载的目标值

Prometheus 查询得到的是所有 Predictor Pod 的请求总数,可以理解为:

1
Query = running 请求数 + waiting 请求数

target.value: 1 表示希望每个副本平均处理 1 个请求。当前副本范围是 1~2,扩缩容过程可以直接这样看:

  • 当前只有 1 个副本时,如果 Query = 0 或 1,保持 1 个副本。
  • 当前只有 1 个副本时,如果 Query >= 2,说明请求超过单个副本的目标值,扩到 2 个副本。
  • 扩容后即使请求继续增加,也不会超过 maxReplicas: 2。
  • 负载停止后,Query 回到 0 或 1,等待 300 秒缩容稳定窗口结束,再缩回 1 个副本。

配置 ServiceMonitor

Prometheus Stack 不会自动采集 qwen-llm。创建 ServiceMonitor,通过 InferenceService Label 选择 KServe 生成的 Predictor Service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
cat <<'EOF' > qwen-llm-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
 name: qwen-llm
 namespace: monitoring
 labels:
 release: prometheus
spec:
 namespaceSelector:
 matchNames:
 - kserve-test
 selector:
 matchLabels:
 serving.kserve.io/inferenceservice: qwen-llm
 endpoints:
 - port: qwen-llm-predictor
 path: /metrics
 interval: 5s
EOF

kubectl apply -f qwen-llm-monitor.yaml

ServiceMonitor 不会通过 Service 的 ClusterIP 随机抓取一个后端。Prometheus Operator 会读取 Service 对应的 EndpointSlice,并把每个 Predictor Pod 都作为独立 Target。

检查指标和扩缩容资源

通过 Kubernetes Service Proxy 检查 Target。当前只有一个 Predictor,因此应看到一行 qwen-llm-predictor-*,状态为 up:

1
2
3
kubectl get --raw \
 '/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/targets?state=active' | \
 jq -r '.data.activeTargets[] | select(.labels.namespace == "kserve-test") | [.labels.pod, .health, .scrapeUrl] | @tsv'

再检查空闲时的指标:

1
2
3
kubectl get --raw \
 '/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/query?query=sum(vllm:num_requests_running%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)%2Bsum(vllm:num_requests_waiting%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)' | \
 jq -r '[.status, .data.resultType, (.data.result | length), .data.result[0].value[1]] | @tsv'

实测输出是:

1
success vector 1 0

KServe 创建 Deployment 和 ScaledObject,KEDA 再创建并管理 HPA:

1
2
3
4
5
6
kubectl get inferenceservice qwen-llm -n kserve-test
kubectl get scaledobject qwen-llm-predictor -n kserve-test
kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test
kubectl get deployment qwen-llm-predictor -n kserve-test
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm

空闲状态下,各条命令输出中的关键字段是下面这些。这里是整理后的字段,不是上一段单条命令的原始输出:

1
2
3
qwen-llm-predictor READY=True ACTIVE=False MIN=1 MAX=2
keda-hpa-qwen-llm-predictor TARGETS=0/1 REPLICAS=1
qwen-llm-predictor READY=1/1

直接读取 HPA,确认缩容稳定窗口:

1
2
kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test \
 -o jsonpath='{.spec.behavior.scaleDown.stabilizationWindowSeconds}{"\n"}'

当前结果是:

1
300

验证自动扩缩容

验证扩容

先查询 Envoy Gateway 的地址,发送一次推理请求,确认服务正常:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ENVOY_SERVICE=$(kubectl get service -n envoy-gateway-system \
 -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway \
 -o jsonpath='{.items[0].metadata.name}')

NODE_PORT=$(kubectl get service -n envoy-gateway-system "$ENVOY_SERVICE" \
 -o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')

NODE_IP=$(kubectl get node \
 -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')

GATEWAY_ADDR="${NODE_IP}:${NODE_PORT}"

curl -H 'Host: qwen-llm-kserve-test.example.com' \
 -H 'Content-Type: application/json' \
 "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
 -d '{
 "model": "qwen",
 "messages": [{"role": "user", "content": "Answer only with the number: 2+3"}],
 "max_tokens": 8,
 "temperature": 0
 }'

产生持续负载

使用以下命令产生负载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
BODY='{"model":"qwen","messages":[{"role":"user","content":"Write a detailed numbered list from 1 to 300, with a complete sentence for every item."}],"max_tokens":768,"temperature":0.7}'
export BODY GATEWAY_ADDR

seq 12 | xargs -n 1 -P 12 bash -c '
 while true; do
 curl -sS --max-time 180 \
 -H "Host: qwen-llm-kserve-test.example.com" \
 -H "Content-Type: application/json" \
 "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
 -d "$BODY" >/dev/null
 done
'

这条命令会一直占用当前终端,并将并发数保持为 12 持续发送请求。

观察扩容结果

另开两个终端,同时观察 Prometheus 指标以及扩缩容资源:

1
watch -n 5 'kubectl get --raw "/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/query?query=sum(vllm:num_requests_running%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)%2Bsum(vllm:num_requests_waiting%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)" | jq -r ".data.result[0].value[1]"'
1
watch -n 5 'kubectl get scaledobject,hpa,deployment,pod -n kserve-test'

本次实测时间线是:

1
2
3
4
启动负载后 Prometheus 查询值升到 12
 ScaledObject ACTIVE=True
 HPA TARGETS=6/1,Deployment REPLICAS=2
约 250 秒后 第二个 vLLM 完成模型加载,两个 Pod 均为 1/1 Running

KServe 模型服务自动扩缩容时间线

指标值是整个 Deployment 的 running + waiting 总数。两个副本、总值 12 时,平均每个副本为 6,因此 HPA 的目标列显示约 6/1。

扩容期间再调用一次 Chat Completions,仍然返回正确结果。至此可以确认 KEDA 已经根据 vLLM 指标把服务从 1 个副本扩到 2 个。

验证缩容

回到运行压测的终端,按 Ctrl+C 停止全部请求。

先重复前面的 Prometheus 查询,确认结果回到 0,再检查 ScaledObject 变成 ACTIVE=False。Deployment 不会立即缩容,因为当前生成的 HPA 带有 300 秒缩容稳定窗口:

1
2
3
behavior:
 scaleDown:
 stabilizationWindowSeconds: 300

实际缩容时间还会受到 KEDA 轮询、HPA 同步周期和 Pod 终止时间影响。用下面的命令等待 Deployment 回到一个 Ready 副本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
 --for=jsonpath='{.status.replicas}'=1 --timeout=8m
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
 --for=jsonpath='{.spec.replicas}'=1 --timeout=2m
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
 --for=jsonpath='{.status.readyReplicas}'=1 --timeout=2m

kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test
kubectl get deployment qwen-llm-predictor -n kserve-test \
 -o custom-columns=NAME:.metadata.name,SPEC:.spec.replicas,STATUS:.status.replicas,READY:.status.readyReplicas
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm

本次停止负载后,Prometheus 查询值回到 0,ScaledObject 变成 ACTIVE=False;约 299 秒后,HPA 将 Deployment 从 2 缩回 1。最后重新执行前面的 Chat Completions 请求,仍然返回 5。

总结

本文验证了模型服务从 1 -> 2 -> 1 的完整链路,最后总结一下整个流程:

  1. KServe 创建 Predictor Deployment 启动模型服务,底层推理引擎 vLLM 通过 /metrics 暴露运行指标。
  2. Prometheus 采集这些指标,例如 vllm:num_requests_running 和 vllm:num_requests_waiting。
  3. KEDA 查询 Prometheus,并通过 External Metrics API 将查询结果暴露为 HPA 可以使用的外部指标。
  4. HPA 比较指标当前值与目标值,计算期望副本数并调整 Predictor Deployment。

KServe 集成 KEDA 的自动扩缩容链路