ᕕ( ᐛ )ᕗ Jimyag's Blog

用 NVIDIA mock GPU 测试 HAMi

没有 GPU 服务器,也可以先测试 HAMi 的调度行为:是否选对设备、是否拒绝超额请求、Pod 删除后是否释放资源。

这次在一台 Linux CPU 主机上,用 NVIDIA 的 k8s-test-infra 模拟了 4 张 A100,部署 HAMi 后测试共享、多卡、显存与算力预算、设备筛选、调度策略、资源回收、监控和重启恢复。33 项检查中,31 项通过,另两项分别遇到了显存视图兼容性问题和 mock 的 CUDA 功能限制。

这些结果能说明调度和配额注入的行为,真实 GPU 上的显存、算力隔离仍需单独测试。

HAMi 解决什么问题

假设一张 GPU 有 40 GiB 显存,一个小推理服务只需要 4 GiB。若以整张卡作为分配单位,服务会占住整卡;若只允许多个容器访问同一设备,又没有显存预算与运行时限制,服务之间容易互相影响。

HAMi 负责设备共享、资源分配和设备感知调度。它支持多种加速器,但具体能力取决于厂商、硬件和后端。HAMi v2.10.0 的项目说明 介绍了这些功能;这次测试使用 NVIDIA 的 hami-core 后端。

常用功能

功能 解决的问题 本文验证程度
GPU 共享 多个 Pod 使用同一张物理卡,各自请求部分资源 验证到 Running Pod 与实际 UUID 分配
显存配额 按绝对容量或百分比分配显存 验证预算、配额注入;显存视图有一项异常
算力配额 为工作负载配置算力比例,并参与调度预算计算 验证预算与环境变量,未验证执行精度
多卡分配 一个容器申请多张物理卡,每张卡分配所请求的资源 验证 2 个不同 UUID
设备选择 按型号、UUID、节点约束选择或排除设备 验证正向与反向用例
binpack / spread 尽量集中到少量节点/卡,或分散到不同节点/卡 两个层级都验证
配额与回收 拒绝无法满足的请求,释放后让等待的 Pod 继续运行 验证设备预算与 namespace 配额
显存超卖 放大调度器可分配的逻辑显存容量 只验证超卖调度,不代表增加了物理显存
监控 查看设备资源分配与运行指标 验证 scheduler 与 monitor 指标
MIG、拓扑感知调度 利用支持的硬件划分实例或选择合适的设备组合 未测试
异构设备后端 用统一的 Kubernetes 工作流管理不同加速器 本文只覆盖 NVIDIA

HAMi-core 通过容器内的库拦截实现部分控制,MIG 则依赖 GPU 硬件划分实例。HAMi 动态 MIG 的实例创建、记录和回收流程需要另行测试,见 v2.10.0 的动态 MIG 说明

资源请求怎么写

一个典型请求是:

1
2
3
4
5
6
# 单容器的 GPU 请求:在一张物理卡上申请部分显存和算力。
resources:
  limits:
    nvidia.com/gpu: 1 # 需要一张物理卡的资源,可与其他 Pod 共享
    nvidia.com/gpumem: 4096 # 每张卡分配 4096 MiB 显存
    nvidia.com/gpucores: 25 # 算力配额为 25/100,不是 25 个 CUDA core

在本文固定的版本和配置中,它表示:

  • 需要 1 张物理 GPU 的资源。
  • 在该卡上分配 4096 MiB 显存。
  • 申请 25 的算力配额,作为 100 分制的比例参与预算计算。

gpucores: 25 表示算力配额,不对应 25 个 CUDA core 或固定的 SM 分区。它与 MIG 的硬件划分不同,实际吞吐量也未必是整卡的 25%,需要用真卡测量。

Node 上的 nvidia.com/gpu 表示可分配的共享名额,Pod 中的同名字段表示需要几张物理卡。本次每个节点有 2 张卡,deviceSplitCount=10,Node 因而公布 20 个名额;Pod 请求 nvidia.com/gpu: 2 时,会分配到 2 张不同的物理卡。固定版本的 README 中也有这个说明。

假 GPU 替换的是哪一层

本文使用的 k8s-test-infra 提供 mock NVML、假设备节点、驱动目录和 CDI 注入配置,上层组件可以据此发现设备、读取属性,但无法执行真实的 GPU 计算。

本次保留了真实的 HAMi scheduler、webhook、NVIDIA device-plugin、HAMi-core 和 monitor,没有启用 HAMi 自带的 mock-device-plugin。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Pod 提交
HAMi webhook:处理资源请求,选择 scheduler / RuntimeClass
kube-scheduler + HAMi extender:筛选节点、选卡、记录分配并绑定
HAMi NVIDIA device-plugin Allocate:把选卡与配额转换成容器配置
NVIDIA runtime / CDI:注入设备节点、NVML 库和配置
容器:HAMi-core hook + mock NVML / CUDA 库

沿着这条调用链,分别检查:

  1. 控制面:Pod 是否被正确修改、调度和绑定;拒绝请求是否有 FailedScheduling 事件。
  2. 容器配置:实际设备节点、UUID、配额环境变量和 hook 是否存在。
  3. 执行效果:CUDA 分配是否被限制,超额是否失败,计算速度是否受控。

这个环境可以测试前两层。执行效果需要完整的 CUDA Driver API 和真实计算能力,超出了本次 mock 的能力范围。上游的 CUDA mock 文档 列出了 vectorAdd 等 CUDA 工作负载的限制。

搭建实验环境

实验日期是 2026 年 9 月 6 日,宿主机为 Linux amd64 CPU 主机。使用一个 control-plane、两个 worker 的独立 kind 集群,不连接已有业务集群。

组件 实测版本
k8s-test-infra main 分支的 874ab0319732981bc79cbc9b01c22c8fd00656e6
HAMi v2.10.0,提交 4707fb02c91c545bc7343ce26dba4c32919f9a3e
HAMi-core HAMi 子模块固定的 b216ba1be1b8e21488d1c7370ed3357b3049aad1
kind / Kubernetes v0.31.0 / v1.35.0
NVIDIA container toolkit test-infra 的 Kind 镜像中固定为 1.19.1-1
模拟设备 每个 worker 2 张 A100-SXM4-40GB,每卡 40960 MiB

完整版本和镜像标识见 versions.json。复现时使用当时的主分支提交,避免版本变化影响结果。

准备 Docker、kind、kubectl、Helm 3、Git、Python 3,确保当前用户可操作 Docker,并能拉取 GitHub、Docker Hub、registry.k8s.ionvcr.io 和 NVIDIA 软件源中的内容。宿主机不需要安装 Go,构建在 Docker 内完成。NVIDIA toolkit 安装在 kind 节点镜像里。

配置、脚本和结果摘要放在 k8sdev/hami-mock-gpu,每个实验使用独立目录。克隆仓库后进入该目录:

1
2
3
4
5
6
git clone https://github.com/jimyag/k8sdev.git
git -C k8sdev checkout --detach 364c3002194ff363797c6f4b3b0b2d4921248802
cd k8sdev/hami-mock-gpu
export HAMI_LAB_DIR="$HOME/opt/hami-lab"
./setup.sh
export KUBECONFIG="$HAMI_LAB_DIR/kubeconfig"

初始化脚本 会拒绝覆盖非空目录或同名 hami-test 集群。脚本由实测命令整理而来,增加了目录检查,尚未在新集群中完整重跑。

下面是脚本中的部署步骤。已经运行 setup.sh 时无需重复执行。

构建 mock 和节点镜像

在实验工作目录执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
mkdir -p src

git clone https://github.com/NVIDIA/k8s-test-infra.git src/k8s-test-infra
git -C src/k8s-test-infra checkout --detach \
  874ab0319732981bc79cbc9b01c22c8fd00656e6

docker build -t hami-test/nvml-mock:main \
  -f src/k8s-test-infra/deployments/nvml-mock/Dockerfile \
  src/k8s-test-infra

docker build -t hami-test/kind-nvidia:local \
  src/k8s-test-infra/deployments/kind-nvidia-cdi

第二个镜像在 kind 节点中配置 NVIDIA runtime 与 CDI,负责向 Pod 注入设备和库。配置来自 固定提交中的 Kind NVIDIA CDI 部署目录

创建三节点集群

kind.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 两个工作节点用于测试节点调度策略;每个节点再模拟两张 GPU。
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
  labels:
    gpu: 'on' # 供 HAMi 和 mock 选择 GPU 节点
- role: worker
  labels:
    gpu: 'on' # 供 HAMi 和 mock 选择 GPU 节点
1
2
3
4
export KUBECONFIG="$HAMI_LAB_DIR/kubeconfig"
kind create cluster --name hami-test \
  --image hami-test/kind-nvidia:local --config kind.yaml
kind load docker-image hami-test/nvml-mock:main --name hami-test

两个 worker 用来区分节点层面的 binpack 与 spread。每个 worker 配置两张卡,则能继续区分卡层面的策略。

为两个节点准备不同 UUID

上游 A100 profile 中的 UUID 是固定值。若两个节点直接复用同一份 profile,会出现重复 UUID。本次为每个 worker 安装一个 mock release,第二份 profile 改用不同前缀:

1
2
3
MOCK_CHART=src/k8s-test-infra/deployments/nvml-mock/helm/nvml-mock
sed 's/GPU-12345678/GPU-22345678/g' \
  "$MOCK_CHART/profiles/a100.yaml" > worker2-a100.yaml

worker1 的 values 如下,worker2 则把 hostname 改为 hami-test-worker2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 第一个工作节点的 mock 配置,镜像由本地构建并导入 kind。
gpu:
  profile: a100
  count: 2 # 该节点只暴露两张模拟 GPU
image:
  repository: hami-test/nvml-mock
  tag: main
  pullPolicy: Never # 使用 kind load 导入的镜像,不从远端拉取
nodeSelector:
  gpu: 'on' # 供 HAMi 和 mock 选择 GPU 节点
  kubernetes.io/hostname: hami-test-worker
1
2
3
4
5
6
7
8
helm upgrade --install nvml-mock "$MOCK_CHART" \
  -n mock-system --create-namespace \
  -f mock-worker1-values.yaml --wait --timeout 180s

helm upgrade --install nvml-mock-worker2 "$MOCK_CHART" \
  -n mock-system -f mock-worker2-values.yaml \
  --set-file gpu.customConfig=worker2-a100.yaml \
  --wait --timeout 180s

完整配置见 worker1 valuesworker2 values。四张卡使用不同 UUID,便于核对共享、筛选和跨节点分配结果。

安装 HAMi

HAMi 的 chart 和镜像都使用 v2.10.0

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
git clone --depth 1 --branch v2.10.0 \
  https://github.com/Project-HAMi/HAMi.git src/HAMi

docker pull projecthami/hami:v2.10.0
docker pull ubuntu:22.04
kind load docker-image projecthami/hami:v2.10.0 ubuntu:22.04 \
  --name hami-test

helm upgrade --install hami src/HAMi/charts/hami \
  -n hami-system --create-namespace \
  -f hami-values.yaml --wait --timeout 240s

hami-values.yaml 中几个关键参数是:

1
2
3
4
5
6
7
# HAMi device-plugin 的实验配置,配合 NVIDIA runtime 的 CDI 注入使用。
devicePlugin:
  deviceSplitCount: 10 # 每张卡提供 10 个共享名额
  passDeviceSpecsEnabled: false # 由 CDI 注入假设备,插件不额外传递 DeviceSpec
  runtimeClassName: nvidia # 选择节点镜像中配置好的 NVIDIA runtime
  createRuntimeClass: true # 由 chart 创建对应的 RuntimeClass
  nvidiaDriverRoot: /run/nvidia/driver # mock 在 kind 节点内提供的驱动目录

本实验设置 passDeviceSpecsEnabled=false,由 NVIDIA runtime 通过 CDI spec 注入假设备。真卡部署需要按所用运行时配置。完整 values 还把 kube-scheduler 镜像版本对齐到 Kubernetes v1.35.0,并将实验服务设为 ClusterIP。

部署完成后,先检查设备注册:

1
2
3
4
5
6
kubectl --context kind-hami-test get pods -n hami-system
kubectl --context kind-hami-test get nodes \
  -o 'custom-columns=NAME:.metadata.name,GPU-SHARES:.status.allocatable.nvidia\.com/gpu'

kubectl --context kind-hami-test get node hami-test-worker \
  -o 'jsonpath={.metadata.annotations.hami\.io/node-nvidia-register}'

本次两个 worker 分别公布 20 份资源。HAMi 节点注解中,每卡记录 count=10devmem=40960devcore=100health=true。检查这些值,可以确认 HAMi 读到的设备数量、容量和健康状态是否符合配置。

跑通第一个 Pod

先建立专用 namespace:

1
kubectl --context kind-hami-test create namespace hami-tests

下面是简化后的测试 Pod,完整版本见 smoke.yaml

 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
# 最小工作负载:挂载 mock CUDA 库,输出运行时信息后保持运行。
apiVersion: v1
kind: Pod
metadata:
  name: smoke
  namespace: hami-tests
spec:
  volumes:
  - name: mock-cuda
    hostPath:
      path: /var/lib/nvml-mock/driver/usr/lib64/libcuda.so.1 # 补充 HAMi-core 的动态链接依赖
      type: File
  containers:
  - name: gpu
    image: ubuntu:22.04
    command: [bash, -c, 'env | sort; nvidia-smi -L; sleep 3600']
    volumeMounts:
    - name: mock-cuda
      mountPath: /usr/lib/x86_64-linux-gnu/libcuda.so.1
      readOnly: true # 容器只读挂载 mock 动态库
    resources:
      limits:
        nvidia.com/gpu: 1 # 需要一张物理卡的资源,可与其他 Pod 共享
        nvidia.com/gpumem: 4096 # 每张卡分配 4096 MiB 显存
        nvidia.com/gpucores: 25 # 算力配额为 25/100,不是 25 个 CUDA core

这里需要额外挂载 CUDA 库。上游 mock CDI spec 注入了 NVML,却没有注入已构建的 libcuda.so.1。HAMi 注入的 libvgpu.so 依赖该库,最初连 bash 都无法启动,报 libcuda.so.1: cannot open shared object file。增加这个只读挂载后,原测试 Pod 可以运行。

1
2
3
4
kubectl --context kind-hami-test apply -f smoke.yaml
kubectl --context kind-hami-test -n hami-tests wait pod/smoke \
  --for=condition=Ready --timeout=120s
kubectl --context kind-hami-test -n hami-tests logs smoke

日志中应该有配额:

1
2
CUDA_DEVICE_MEMORY_LIMIT_0=4096m
CUDA_DEVICE_SM_LIMIT=25

继续检查分配注解:

1
2
kubectl --context kind-hami-test -n hami-tests get pod smoke \
  -o 'jsonpath={.metadata.annotations.hami\.io/vgpu-devices-allocated}'

本次得到的一个实际分配是:

1
GPU-12345678-1234-1234-1234-123456780000,NVIDIA,4096,25:;

同时核对 spec.schedulerName=hami-schedulerspec.runtimeClassName=nvidiahami.io/bind-phase=success,确认 Pod 经过了 HAMi 调度并完成分配。

功能测试

脚本提供 33 项检查,在 k8sdev/hami-mock-gpu 目录执行:

1
2
export KUBECONFIG="$HAMI_LAB_DIR/kubeconfig"
./run.sh

脚本会清理 hami-tests 中的 Pod、重启实验用 HAMi 组件,并短暂调整显存 scaling 后恢复,因而应只在独立实验集群中运行。它会保留逐项输入、Pod JSON、事件和日志。有检查项失败时,脚本返回非零退出码;本次固定版本有后文记录的两个已知失败。

证据保存在 $HAMI_LAB_DIR/evidence/。入口脚本会拒绝覆盖已有的 matrix 结果目录,复跑前应先备份已有测试结果。

以下各组分别检查共享、预算、选卡和资源回收。

同卡共享和份数限制

连续提交 3 个 Pod,每个请求 4096 MiB、25 cores,并指定同一张卡:

1
2
3
4
5
6
7
# 固定节点与 GPU UUID,确认多个 Pod 共享同一张物理卡。
metadata:
  annotations:
    nvidia.com/use-gpuuuid: GPU-12345678-1234-1234-1234-123456780000
spec:
  nodeSelector:
    kubernetes.io/hostname: hami-test-worker

三个 Pod 都应 Running,分配注解中的 UUID 也应相同,以确认它们共享同一张卡。

再用每个 Pod 64 MiB、1 core 的小请求验证份数:前 10 个运行,第 11 个因共享名额耗尽而等待;删除一个已运行 Pod 后,第 11 个自动运行。即使还有剩余显存和算力,分配也受 deviceSplitCount 限制。

超额请求与资源回收

显存测试固定同一个 UUID:

  1. 第一个 Pod 请求 30000 MiB。
  2. 第二个请求 12000 MiB,两者相加超过单卡 40960 MiB。
  3. 第二个应没有 nodeName,并出现 FailedScheduling。
  4. 删除第一个,第二个应在不重新提交的情况下运行。

算力测试用 60 与 50 的请求重复相同步骤,两组都通过了预算检查和资源回收测试。容器没有执行 CUDA 分配或计算,所以这里没有测量实际显存占用和算力利用率。

测试独占时,先请求目标卡的 100% 显存和 100 cores,再提交一个很小的同卡请求,后者应被阻塞。

另外,v2.10.0 的 gpucores: 101 并没有被拒绝。实测分配注解与容器环境变量都变为 100;NVIDIA Fit 源码 中有截为 100 的逻辑,因此该版本的测试预期应是按 100 分配。

多卡与显存百分比

nvidia.com/gpu 改为 2,应在同一节点分配两个不同 UUID;每张卡都分配请求的显存与算力。本文还验证了两个容器各自请求 GPU 的 Pod,两者均有分配并正常运行。

百分比请求使用:

1
2
3
4
5
6
# 使用显存百分比请求:40960 MiB 的 25% 为 10240 MiB。
resources:
  limits:
    nvidia.com/gpu: 1 # 需要一张物理卡的资源,可与其他 Pod 共享
    nvidia.com/gpumem-percentage: 25 # 按单卡总显存的 25% 分配
    nvidia.com/gpucores: 25 # 算力配额为 25/100,不是 25 个 CUDA core

在本次 40960 MiB 的卡上,分配结果应为 10240 MiB。测试百分比时移除绝对显存字段,避免把两个输入的优先级混入这个用例。

节点与 GPU 调度策略

HAMi 支持分别指定节点与 GPU 策略:

1
2
3
4
5
# 节点尽量分散,节点内的 GPU 分配尽量集中。
metadata:
  annotations:
    hami.io/node-scheduler-policy: spread # 两个 Pod 尽量调度到不同节点
    hami.io/gpu-scheduler-policy: binpack # 节点内尽量复用已有分配的卡

测试卡级策略时,先用 nodeSelector 固定节点,再提交两个小请求:binpack 应选择同一 UUID,spread 应选择不同 UUID。测试节点级策略时解除节点约束:binpack 应选择同一节点,spread 应选择不同节点。

每组测试前清理前一组的 Pod,避免已有负载影响预期。本次四组测试的节点名和 UUID 都符合预期。上游策略示例 给出了这两个注解。

设备选择也应有正反两组:use-gputype: A100 应通过,use-gputype: T4 应等待;nouse-gputype: A100 应排除当前节点上的卡。UUID 白名单和黑名单则核对最终选中的具体 UUID。

显存超卖

本次将 deviceMemoryScaling 临时改为 2:

1
2
3
helm upgrade hami "$HAMI_LAB_DIR/src/HAMi/charts/hami" \
  -n hami-system -f "$HAMI_LAB_DIR/hami-values.yaml" \
  --set devicePlugin.deviceMemoryScaling=2 --wait

等待设备重新注册后,每卡逻辑显存变为 81920 MiB。再提交 61440 MiB 请求,Pod 可以分配并运行。测试容器只执行 sleep,因此这里验证了超卖调度,没有实际申请 60 GiB 显存。

测试结束后,重新使用原始 hami-values.yaml 升级,等待注册值恢复到每卡 40960 MiB。脚本将恢复步骤放在 finally 中,并再次检查注册值。

监控与重启恢复

scheduler 的指标可以经 Kubernetes API 的 service proxy 读取,无需对宿主机额外开放端口:

1
2
kubectl --context kind-hami-test get --raw \
  /api/v1/namespaces/hami-system/services/hami-scheduler:31993/proxy/metrics

本次固定一张卡分配 4096 MiB 后,hami_gpu_memory_allocated_bytes 对应值为 4294967296;删除 Pod 后变为 0。指标更新有延迟,脚本会等待它变为预期值。

device-plugin monitor 的服务端点也返回了 hami_host_gpu_* 指标。不过它读取的是 mock 数据,不能把利用率、已用显存等读数当作实际 GPU 负载。

重启恢复则检查两件事:重启 HAMi scheduler 和 device-plugin 后,已有 Pod 的 UID、分配保持不变,容器没有重启;再提交的新 Pod 仍能调度。这两项都通过了;节点掉电、驱动重载和 GPU 故障恢复未测试。

测试中遇到的问题

GPU 0 显示 4 GiB,GPU 1 却仍显示 40 GiB

在本次 runtime 下,初始容器进程拥有 CDI 注入的 MOCK_NVML_CONFIG,但 kubectl exec 新建的进程没有继承这部分变量。直接 exec 查询可能得到 mock 的默认配置。查询时需要显式带上配置:

1
2
3
kubectl --context kind-hami-test -n hami-tests exec POD -- env \
  MOCK_NVML_CONFIG=/etc/nvml-mock/config.yaml \
  nvidia-smi --query-gpu=index,uuid,memory.total --format=csv,noheader

即使补上配置,两个物理索引仍有差异。同样请求 4096 MiB:

1
2
物理 GPU 0:0, GPU-12345678-1234-1234-1234-123456780000, 4096 MiB
物理 GPU 1:1, GPU-12345678-1234-1234-1234-123456780001, 40960 MiB

原始查询分别见 GPU 0GPU 1。两个 Pod 的 HAMi 分配和 CUDA_DEVICE_MEMORY_LIMIT_0=4096m 都正确,异常出在显存查询结果。

从源码看,可能与设备索引映射有关:mock 过滤设备后,容器中的设备数量是 1,但 GPU 1 的 nvmlDeviceGetIndex 仍返回物理索引 1;HAMi-core 的 NVML 初始化使用初始的 CUDA/NVML 索引映射,映射函数只遍历设备数量范围。找不到物理索引 1 时,显存查询返回原始容量。

对应代码是 mock 的设备实现HAMi-core NVML hook索引映射函数。本次将它记为 mock 与 HAMi-core 的兼容性异常,尚未在真卡上复现。

vectorAdd 无法运行

CUDA 测试使用 NVIDIA 官方样例镜像:

1
nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0

带 HAMi-core hook 时,Pod 失败,错误为:

1
Hijack failed: cuGetProcAddress_v2 is NULL

再设置 CUDA_DISABLE_CONTROL=true,让 device-plugin 不注入 HAMi preload,样例仍然失败:

1
CUDA driver version is insufficient for CUDA runtime version

无 hook 的控制实验 也未通过,与该版本 mock 缺少完整 CUDA Driver API 的限制一致。这一项记为「不支持验证」,调度本身已完成。

后续测试与清理

本次通过的 31 项主要涉及资源请求、调度、Allocate 注入和资源回收,另外记录了两项 mock 兼容性或功能限制。

运行时隔离需要用真卡补测:让 CUDA 程序申请低于和高于配额的显存,检查超限行为;并发运行计算任务,比较不同算力配额下的利用率、吞吐和延迟;再测试多进程、进程异常退出、不同物理索引和 MIG。Pod 状态、分配注解和 nvidia-smi 输出只能作为其中一部分检查。

实验结束后可按需删除独立集群:

1
kind delete cluster --name hami-test

该命令保留构建镜像、源码和证据目录。

实验文件:

仓库中的脚本整理了路径、检查入口和重复用例;测试结果取自整理前的实际运行。

#Kubernetes #GPU #HAMi #Kind