TKE RTX 5090D 2卡分配:nvidia-device-plugin v0.18.2 alignedAlloc 升级验证
环境:腾讯云 TKE,北京区,RTX 5090D 8 卡节点;
nvidia-device-plugin从v0.14.5对比到v0.18.2-tke.1。
目标:验证 2 卡 Pod 是否能稳定拿到拓扑关系更近的 GPU,避免跨 NUMA 的SYS组合。
结论先放前面
这次升级解决的是“2 卡 Pod 分配到差拓扑 GPU”的问题,不是解决 RTX 5090D 的 CUDA P2P 支持问题。
验证结果是:
- 升级前:
nvidia-device-plugin v0.14.5,40 个样本里出现过跨 NUMA 的SYS组合。 - 升级后:
nvidia-device-plugin v0.18.2-tke.1,40 个样本全部是NODE,没有再出现SYS。 - 但即使拿到
NODE或PIX,也不能证明 GPU-GPU CUDA P2P 可用;RTX 5090D 这批节点实测cudaDeviceCanAccessPeer=0。
所以这次优化的准确表述是:
TKE 5090 节点上,通过升级
nvidia-device-plugin到带拓扑亲和分配逻辑的版本,让 2 卡 Pod 更稳定地拿到同 NUMA 内的 GPU,避免跨 NUMA 的SYS组合。

下面这张动图把验证路径压缩成 5 步:

1. 原始问题是什么
业务同事的原始诉求不是“要 MPS”,也不是“要 P2P”,而是:
同一个 Pod 申请 2 张 GPU 时,不希望随机拿到拓扑最差的一对卡。
在 8 卡机器上,nvidia-smi topo -m 里常见几种关系:
SYS:跨 NUMA,路径要经过 CPU socket 间互联,通常最差。NODE:同 NUMA 内,路径不跨 socket。PHB/PXB/PIX:PCIe 层级更近,PIX通常比NODE更近。NV#:NVLink,消费级 4090/5090 机器上一般没有。
这次 TKE 5090 节点的典型拓扑是:
GPU0-3 在 NUMA 0
GPU4-7 在 NUMA 1
同 NUMA 内是 NODE
跨 NUMA 是 SYS
所以对 2 卡 Pod 来说,至少要避免一张落在 GPU0-3,另一张落在 GPU4-7 这种跨 NUMA 组合。
问题到这里还差一层:Kubernetes 调度器只能决定 Pod 放到哪台节点,它并不负责决定容器里最终看到的是 GPU0、GPU1,还是 GPU0、GPU4。
真正“挑哪几张卡”的动作发生在节点本地,由 kubelet 调用 nvidia-device-plugin 的 Allocate 接口完成。也就是说,如果要避免 2 卡 Pod 拿到跨 NUMA 的 SYS 组合,优化点不在业务 Pod,也不在调度器,而在 device plugin 的分配策略。
这就是 alignedAlloc 和 BestEffortPolicy 出现的位置。
2. alignedAlloc 和 BestEffortPolicy 是干什么的
这两个词可以这么理解:
alignedAlloc:分配 GPU 时尽量按拓扑关系对齐,优先选择更近的一组卡。BestEffortPolicy:如果找不到完全理想的组合,也不要直接失败,而是在可用 GPU 里选一个相对更好的组合。
换句话说,它不是让 GPU 本身变快,也不是打开 P2P;它只是在 kubelet 调用 device plugin 分配 GPU 时,改变“从可用卡里挑哪几张”的策略。
这正好接上原始需求:2 卡 Pod 不要拿到跨 NUMA 的 SYS 卡组。
3. nvidia-device-plugin 是怎么工作的
这里真正起作用的组件是 nvidia-device-plugin。
Kubernetes 本身不直接知道一台机器上有哪些 NVIDIA GPU,也不知道应该把哪几张卡分给 Pod。GPU 这类设备资源是通过 device plugin 机制接入 kubelet 的。
可以把链路拆成 5 步:
nvidia-device-plugin运行在每个 GPU 节点上。- 它通过 NVML 发现本机 GPU,比如 GPU0 到 GPU7、UUID、健康状态、拓扑信息。
- 它把这些 GPU 注册给 kubelet,告诉 kubelet:这个节点有
nvidia.com/gpu=8。 - Pod 申请 GPU 时,例如
limits: nvidia.com/gpu: 2,调度器只负责把 Pod 放到“还有 2 张 GPU 可用”的节点上。 - Pod 真正创建时,kubelet 调用 device plugin 的
Allocate接口,由 device plugin 决定“这 2 张具体是哪两张卡”,然后把结果通过环境变量或 CDI 等方式交给容器运行时。
这次升级影响的是第 5 步。
老版本更偏向“从可用 GPU 里拿够数量”。如果一台 8 卡机器上刚好空闲卡分布在两个 NUMA 上,就可能给 2 卡 Pod 分到一张 NUMA0、一张 NUMA1,Pod 内看到的就是 SYS。
新版本引入拓扑亲和分配后,分配逻辑会先看可用 GPU 之间的关系,尽量选择同 NUMA 或拓扑更近的一组卡。第 2 节里的 alignedAlloc 和 BestEffortPolicy,就是在这个 Allocate 阶段生效。
一句话:nvidia-device-plugin 是 kubelet 和 NVIDIA GPU 之间的设备分配层;这次升级就是把“够 2 张就行”的分配,改成“够 2 张,并且尽量拓扑更近”。
4. 升级了什么
升级对象是 TKE 集群里的 nvidia-device-plugin DaemonSet。
最终版本:
image: ccr.ccs.tencentyun.com/tkeimages/nvidia-device-plugin:v0.18.2-tke.1
binary: v0.18.2-fix1
commit: fb1242add205d8563f9c604b8ba239607b4083e6-dirty
对比版本:
image: ccr.ccs.tencentyun.com/tkeimages/nvidia-device-plugin:v0.14.5
binary: 3d549fbb
测试时确认过,旧镜像也支持 --config-file 和 config-manager,所以回退验证是可行的,不是因为配置无法加载导致的假对比。
5. 怎么验证
验证方式不是只创建一个 Pod 看一次,因为一次结果不能证明分配策略稳定。
实际用的是重复采样:
- 每轮并行创建 2 个 Pod。
- 每个 Pod 申请 2 张 GPU。
- 每次跑 10 轮,得到 20 个样本。
- 升级前跑 2 次,共 40 个样本。
- 升级后跑 2 次,共 40 个样本。
Pod 内执行:
nvidia-smi --query-gpu=index,uuid,pci.bus_id --format=csv
nvidia-smi topo -m
测试 Pod YAML
下面是脱敏后的最小测试 Pod。实际使用时只需要替换 namespace、nodeName 和镜像地址。
apiVersion: v1
kind: Pod
metadata:
name: gpu-pair-topology-test
namespace: gpu-test
spec:
restartPolicy: Never
nodeName: 192.168.1.1 # 替换成目标 GPU 节点名或节点 IP
containers:
- name: cuda
image: nvidia/cuda:12.4.1-base-ubuntu22.04
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "-lc"]
args:
- |
nvidia-smi --query-gpu=index,uuid,pci.bus_id --format=csv
nvidia-smi topo -m
resources:
requests:
nvidia.com/gpu: "2"
limits:
nvidia.com/gpu: "2"
重复验证脚本
下面脚本每轮并行创建 2 个 2 卡 Pod,默认跑 10 轮,最后输出 TSV。这里的 PCI bus 到 GPU 编号、NUMA 的映射需要按目标节点的 nvidia-smi topo -m 调整。
#!/usr/bin/env bash
# 用途:在指定 GPU 节点上反复并行创建 2 个 2卡 Pod,验证 device plugin 是否避开跨 NUMA 的 SYS 分配
set -u
KUBECONFIG_PATH=${KUBECONFIG_PATH:-/path/to/kubeconfig}
NAMESPACE=${NAMESPACE:-gpu-test}
NODE_NAME=${NODE_NAME:-192.168.1.1}
ROUNDS=${ROUNDS:-10}
PODS_PER_ROUND=${PODS_PER_ROUND:-2}
PREFIX=${PREFIX:-gpu-pair-topology-test}
IMAGE=${IMAGE:-nvidia/cuda:12.4.1-base-ubuntu22.04}
WAIT_SECONDS=${WAIT_SECONDS:-180}
RESULT_FILE=${RESULT_FILE:-./gpu_pair_topology_results.tsv}
# 按目标机器的 nvidia-smi topo -m 输出调整。
# 示例:GPU0-3 属于 NUMA0,GPU4-7 属于 NUMA1。
GPU_BUS_MAP=${GPU_BUS_MAP:-"
00000000:21:00.0 GPU0 0
00000000:31:00.0 GPU1 0
00000000:41:00.0 GPU2 0
00000000:61:00.0 GPU3 0
00000000:81:00.0 GPU4 1
00000000:A1:00.0 GPU5 1
00000000:C1:00.0 GPU6 1
00000000:E1:00.0 GPU7 1
"}
cat > "$RESULT_FILE" <<'RESULT_HEADER'
round pod phase gpu_pair pci_pair numa_pair topo result
RESULT_HEADER
pod_yaml() {
local pod=$1
cat <<YAML
apiVersion: v1
kind: Pod
metadata:
name: ${pod}
namespace: ${NAMESPACE}
spec:
restartPolicy: Never
nodeName: ${NODE_NAME}
containers:
- name: cuda
image: ${IMAGE}
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "-lc"]
args:
- |
nvidia-smi --query-gpu=index,uuid,pci.bus_id --format=csv
nvidia-smi topo -m
resources:
requests:
nvidia.com/gpu: "2"
limits:
nvidia.com/gpu: "2"
YAML
}
classify_log() {
awk -v map="$GPU_BUS_MAP" '
BEGIN {
split(map, lines, "\n")
for (i in lines) {
if (lines[i] ~ /^[[:space:]]*$/) continue
split(lines[i], f, /[[:space:]]+/)
pci_to_gpu[f[1]]=f[2]
pci_to_numa[f[1]]=f[3]
}
}
/^[0-9]+, GPU-/ {
split($0, a, ", ")
pci=a[3]
g[++n]=pci_to_gpu[pci]
p[n]=pci
numa[n]=pci_to_numa[pci]
}
/^GPU0[[:space:]]/ {
for (i=1; i<=NF; i++) {
if ($i ~ /^(NODE|SYS|PHB|PXB|PIX|NV[0-9]+)$/) {
topo=$i
break
}
}
}
END {
if (n != 2) {
print "UNKNOWN\tUNKNOWN\tUNKNOWN\tUNKNOWN\tFAIL_PARSE"
exit
}
result=(numa[1] == numa[2] && topo != "SYS") ? "PASS" : "FAIL"
print g[1]"+"g[2]"\t"p[1]"+"p[2]"\tNUMA"numa[1]"+NUMA"numa[2]"\t"topo"\t"result
}'
}
kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" get namespace "$NAMESPACE" >/dev/null
for round in $(seq -w 1 "$ROUNDS"); do
pods=""
echo "## round $round create ${PODS_PER_ROUND} pods in parallel"
for n in $(seq 1 "$PODS_PER_ROUND"); do
pod="${PREFIX}-${round}-${n}"
pods="$pods $pod"
kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" delete pod "$pod" \
--ignore-not-found=true --wait=true >/dev/null 2>&1 || true
done
for pod in $pods; do
pod_yaml "$pod" | kubectl --kubeconfig="$KUBECONFIG_PATH" apply -f - &
done
wait
deadline=$((SECONDS + WAIT_SECONDS))
while [ "$SECONDS" -lt "$deadline" ]; do
done_count=0
for pod in $pods; do
phase=$(kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" get pod "$pod" \
-o jsonpath="{.status.phase}" 2>/dev/null || true)
if [ "$phase" = "Succeeded" ] || [ "$phase" = "Failed" ]; then
done_count=$((done_count + 1))
fi
done
[ "$done_count" -eq "$PODS_PER_ROUND" ] && break
sleep 2
done
for pod in $pods; do
phase=$(kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" get pod "$pod" \
-o jsonpath="{.status.phase}" 2>/dev/null || true)
log=$(kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" logs "$pod" 2>/dev/null || true)
parsed=$(printf "%s\n" "$log" | classify_log)
printf "%s\t%s\t%s\t%s\n" "$round" "$pod" "${phase:-UNKNOWN}" "$parsed" | tee -a "$RESULT_FILE"
done
for pod in $pods; do
kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" delete pod "$pod" \
--wait=false >/dev/null 2>&1 || true
done
for pod in $pods; do
kubectl --kubeconfig="$KUBECONFIG_PATH" -n "$NAMESPACE" wait --for=delete "pod/$pod" \
--timeout=90s >/dev/null 2>&1 || true
done
sleep 3
done
cat "$RESULT_FILE"
执行方式:
chmod +x ./gpu-pair-repeat-verify.sh
KUBECONFIG_PATH=/path/to/kubeconfig \
NAMESPACE=gpu-test \
NODE_NAME=192.168.1.1 \
ROUNDS=10 \
PODS_PER_ROUND=2 \
./gpu-pair-repeat-verify.sh
然后检查 Pod 内两张卡之间的拓扑关系。
判断标准:
PASS:两张卡是NODE/PIX/PXB/PHB/NV#等非SYS。FAIL:两张卡是SYS,说明跨 NUMA。
6. 升级前后结果
汇总结果:
| 阶段 | device plugin 版本 | 样本数 | SYS 次数 | 结论 |
|---|---|---|---|---|
| 升级前 | v0.14.5 | 40 | 出现过 | 不能稳定避免差拓扑 |
| 升级后 | v0.18.2-tke.1 | 40 | 0 | 稳定拿到同 NUMA 内组合 |
升级后的典型输出:
GPU0 X NODE
GPU1 NODE X
CPU Affinity: 0-95
NUMA Affinity: 0
另一组:
GPU0 X NODE
GPU1 NODE X
CPU Affinity: 96-191
NUMA Affinity: 1
这说明两张卡在同一个 NUMA 范围内,没有跨到 SYS。
7. 结果边界:NODE 不是 NV,也不等于 CUDA P2P
升级后的结果说明 2 卡 Pod 没有再拿到跨 NUMA 的 SYS 组合。但这个结论不能继续外推成“有 NVLink”或“一定支持 CUDA P2P”。
为什么不是 NV
NV# 代表 NVLink。
这类 RTX 4090 / RTX 5090D 8 卡节点没有看到 NVLink 拓扑,所以 nvidia-smi topo -m 里不会出现 NV1/NV2 这种关系。
对这批机器,目标不是从 SYS 优化到 NV,而是从“可能跨 NUMA 的 SYS”优化到“同 NUMA 内的 NODE 或更近关系”。
NODE/PIX 不等于 CUDA P2P
nvidia-smi topo -m 里的 NODE/PIX/PXB/PHB 描述的是 PCIe/NUMA 拓扑距离,不等于 CUDA 层面的 peer access 一定可用。
在 TKE 5090D 节点上,实际编译 CUDA 程序调用 cudaDeviceCanAccessPeer,结果是:
can_access_peer 0 -> 1 = 0
can_access_peer 1 -> 0 = 0
cudaDeviceEnablePeerAccess: peer access is not supported between these two devices
所以要分清两层:
- device plugin 拓扑亲和:解决“Pod 分到哪几张卡”。
- CUDA P2P:解决“这几张卡之间能不能直接 peer access”。
这次升级解决的是前者,不是后者。
8. 结论口径
可以这样说明:
我们对 TKE 的
nvidia-device-plugin做了升级,从v0.14.5升级到v0.18.2-tke.1。这类方案的作用是通过 GPU 拓扑亲和分配,让 2 卡 Pod 优先拿到同 NUMA 内的 GPU,避免跨 NUMA 的SYS组合。做了一次升级前后的对比测试:升级前 40 个样本里出现过
SYS;升级后重新跑 40 个样本,全部是NODE,没有再出现SYS。需要说明的是,这个优化解决的是 GPU 分配拓扑问题,不代表 RTX 5090D 支持 CUDA P2P。P2P 已单独实测,当前 5090D 节点上
cudaDeviceCanAccessPeer=0。
快速参考
查看 Pod 内拿到的 GPU:
nvidia-smi --query-gpu=index,uuid,pci.bus_id --format=csv
查看 GPU 拓扑:
nvidia-smi topo -m
判断:
- 出现
SYS:跨 NUMA,不符合这次 2 卡亲和目标。 - 出现
NODE:同 NUMA,符合本次优化目标。 - 出现
PIX/PXB/PHB:PCIe 层级更近,但仍不等于 CUDA P2P。 - 出现
NV#:NVLink;这批 4090/5090D 节点没有看到。
验证 CUDA P2P:
cudaDeviceCanAccessPeer(&can_access, 0, 1);
如果返回 0,即使 nvidia-smi topo -m 看起来是 NODE/PIX,也不能使用 CUDA peer access。

浙公网安备 33010602011771号