TKE RTX 5090D 2卡分配:nvidia-device-plugin v0.18.2 alignedAlloc 升级验证

环境:腾讯云 TKE,北京区,RTX 5090D 8 卡节点;nvidia-device-pluginv0.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
  • 但即使拿到 NODEPIX,也不能证明 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-pluginAllocate 接口完成。也就是说,如果要避免 2 卡 Pod 拿到跨 NUMA 的 SYS 组合,优化点不在业务 Pod,也不在调度器,而在 device plugin 的分配策略。

这就是 alignedAllocBestEffortPolicy 出现的位置。

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 步:

  1. nvidia-device-plugin 运行在每个 GPU 节点上。
  2. 它通过 NVML 发现本机 GPU,比如 GPU0 到 GPU7、UUID、健康状态、拓扑信息。
  3. 它把这些 GPU 注册给 kubelet,告诉 kubelet:这个节点有 nvidia.com/gpu=8
  4. Pod 申请 GPU 时,例如 limits: nvidia.com/gpu: 2,调度器只负责把 Pod 放到“还有 2 张 GPU 可用”的节点上。
  5. Pod 真正创建时,kubelet 调用 device plugin 的 Allocate 接口,由 device plugin 决定“这 2 张具体是哪两张卡”,然后把结果通过环境变量或 CDI 等方式交给容器运行时。

这次升级影响的是第 5 步。

老版本更偏向“从可用 GPU 里拿够数量”。如果一台 8 卡机器上刚好空闲卡分布在两个 NUMA 上,就可能给 2 卡 Pod 分到一张 NUMA0、一张 NUMA1,Pod 内看到的就是 SYS

新版本引入拓扑亲和分配后,分配逻辑会先看可用 GPU 之间的关系,尽量选择同 NUMA 或拓扑更近的一组卡。第 2 节里的 alignedAllocBestEffortPolicy,就是在这个 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-fileconfig-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。实际使用时只需要替换 namespacenodeName 和镜像地址。

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。

posted @ 2026-07-29 16:01  Hello_worlds  阅读(14)  评论(0)    收藏  举报