GPU 虚拟化原理
在 AI 推理场景中,一个常见的困境是:GPU 很贵,但大多数时候都是闲的。
一个典型的推理服务往往只占用 GPU 20%~40% 的算力和少量显存,剩余资源就这样空转着。Kubernetes 的默认 GPU 调度模型偏偏是独占的:nvidia.com/gpu: 1 意味着整张卡归你,其他 Pod 一律等待。想让多个推理服务共享一张 GPU?标准 Device Plugin 做不到,因为它只能向调度器上报设备数量(整数),根本没有"显存配额"这个概念。
于是出现了各种 GPU 共享方案。NVIDIA 官方的时间切片(Time-Slicing)可以让多个 Pod 同时被调度,但没有显存隔离,一个 Pod OOM 会拖垮整张卡上的所有任务。MIG 硬件分区有真正的隔离,但只有 A100、H100 这类数据中心级卡才支持。
HAMi 走了另一条路:不改驱动、不改应用,通过 CUDA API 劫持在软件层实现 GPU 虚拟化,多个 Pod 共享同一张物理 GPU,每个 Pod 只能"看到"自己申请的那部分显存,超额分配直接返回 OOM。这是一个 CNCF Sandbox 项目,前身为 k8s-vGPU-scheduler。
本文先从 Kubernetes GPU 调度的原理讲起,理解默认模型的局限性,再深入 HAMi 的架构和实现,看它是如何绕过这些限制的。
Kubernetes GPU 调度原理
Device Plugin
Kubernetes 原生不直接管理 GPU 等异构硬件资源。为此,Kubernetes 提供了 Device Plugin 扩展机制,允许硬件厂商将自定义设备资源注册到 Kubelet,供调度器使用。
Device Plugin 本身以 DaemonSet 方式部署,运行在每个 GPU 节点上,负责向 Kubelet 注册设备、上报资源、响应分配请求。下图展示了从 Device Plugin 启动到 GPU Pod 运行的完整时序:
各步骤说明如下:
| 步骤 | 参与方 | 说明 |
|---|---|---|
| ① | Kubelet | 启动时创建 Registration gRPC 服务,监听 kubelet.sock,等待 Device Plugin 注册 |
| ② | Device Plugin | DaemonSet Pod 启动,将宿主机 kubelet.sock 挂载到容器内,作为与 Kubelet 通信的入口 |
| ③ | Device Plugin → Kubelet | 通过 kubelet.sock 调用 Register 接口,上报自身的 Unix Socket 路径、API 版本、资源名称(如 nvidia.com/gpu) |
| ④ | Kubelet → Device Plugin | 注册成功后,Kubelet 反向通过 Device Plugin 的 Unix Socket 调用 ListAndWatch,获取当前节点的设备列表,并持续监听设备上下线事件 |
| ⑤ | Kubelet → API Server | 将发现的设备数量同步到 API Server,体现在 Node.status.capacity 中(如 nvidia.com/gpu: 1) |
| ⑥ | 用户 → API Server | 用户提交 Pod,声明 nvidia.com/gpu: 1 资源需求 |
| ⑦ | kube-scheduler | 从 API Server 读取 Node 资源信息,筛选满足条件的节点,将 Pod 绑定到目标节点(写入 Pod.spec.nodeName) |
| ⑧ | Kubelet → Device Plugin | 目标节点的 Kubelet 感知到有 Pod 待启动,调用 Device Plugin 的 Allocate 接口,传入需要分配的设备 ID |
| ⑨ | Device Plugin → Kubelet | 返回具体的设备文件路径(如 /dev/nvidia0)、环境变量(NVIDIA_VISIBLE_DEVICES 等),Kubelet 将其注入容器并启动 |
Device Plugin 有一个根本局限:ListAndWatch 接口只能上报设备数量(整数),调度器完全无法感知设备的具体属性:显存多大、什么型号、NUMA 拓扑如何。这也是 HAMi 不得不借道 Node Annotation 传递 GPU 规格的原因。
DRA (Dynamic Resource Allocation)
DRA 是 Kubernetes 为此设计的下一代方案,v1.34 升级为 GA。它引入了一套新的 API:
ResourceClaim:Pod 申领设备资源的声明,类似 PVC 之于 PVDeviceClass:描述一类设备的规格和筛选条件,支持 CEL 表达式细粒度匹配(如"显存 ≥ 16GB 且型号为 A100")ResourceSlice:设备驱动向 API Server 上报的可用设备列表,携带完整属性
调度器可以直接读取 ResourceSlice 中的设备属性做调度决 策。DRA 还原生支持多个 Pod 共享同一设备,以及跨容器的设备拓扑对齐。
HAMi 目前仍基于 Device Plugin,但官方已启动 HAMi-DRA 子项目(v0.1.0,需要 Kubernetes 1.34+),通过 MutatingWebhook 将 HAMi 的 GPU 资源请求转换为 DRA 的 ResourceClaim,作为向 DRA 迁移的过渡方案。
HAMi 虚拟 GPU 调度原理
HAMi 同时用了三种 Kubernetes 扩展机制(MutatingWebhook、Scheduler Extender 和 Device Plugin),让它们各司其职,做到了:
- 细粒度资源声明:用户可以声明
nvidia.com/gpumem(显存 MiB)和nvidia.com/gpucores(算力 %) - 感知调度:Scheduler-Extender 读取节点 Annotation 中的 GPU 规格,按显存/算力剩余量做 Filter 和 Bind
- 容器内隔离:通过
libvgpu.so在 CUDA API 层拦截,硬性限制容器实际使用的显存和算力
架构与核心组件
HAMi 由四个核心组件构成:
| 组件 | 类型 | 职责 |
|---|---|---|
HAMi MutatingWebhook Server | Deployment(内嵌于 hami-scheduler Pod) | 准入入口:扫描 Pod 资源字段,将需要 HAMi 调度的 Pod 的 schedulerName 改写为 hami-scheduler(可配置);已显式指定其他 schedulerName 的 Pod 会被跳 过 |
HAMi Scheduler-Extender | Deployment(内嵌于 hami-scheduler Pod) | 调度核心:感知全局 GPU 视图,在 Filter/Bind 阶段实现细粒度显存/算力感知调度,支持 binpack/spread 策略 |
HAMi Device Plugin | DaemonSet | 节点资源层:向 Kubelet 注册虚拟 GPU 资源;在 Allocate 中以 hostPath 方式将 libvgpu.so 和 ld.so.preload 挂载到容器,并注入 CUDA_DEVICE_MEMORY_LIMIT_<index>、CUDA_DEVICE_SM_LIMIT 等环境变量 |
HAMi-Core(libvgpu.so) | 动态库(Device Plugin Allocate 时注入) | 容器内软隔离:重写 dlsym 劫持以 cu / nvml 开头的 NVIDIA 库函数,实现显存上限拦截与算力限速 |
实际部署后的 Pod 状态如下:
$ kubectl -n hami-system get pod
NAME READY STATUS RESTARTS AGE
hami-device-plugin-5gn6j 2/2 Running 0 25h ← GPU 节点 1(节点代理层)
hami-device-plugin-qzc78 2/2 Running 0 29h ← GPU 节点 2(节点代理层)
hami-scheduler-8647f67d84-zr42b 2/2 Running 0 29h ← 调度控制层(全局唯一)
下图展示三层架构的组件构成及其通信关系:
工作流程详解
第一步:设备注册与资源上报
hami-device-plugin 启动后做两件事:
① 向 Kubelet 虚报 GPU 数量
将 1 块物理 GPU 虚报为 N 个逻辑 GPU 资源(默认 10 个),使 kube-scheduler 认为节点有 10 个 GPU 可分配:
# kubectl get node <gpu-node> -o yaml 中的可分配资源
nvidia.com/gpu: "10" # 原本 1 块卡,虚报为 10
② 将设备详细规格写入 Node Annotation
标准 Device Plugin 的 ListAndWatch 接口只能上报设备数量(整数),无法携带显存大小、UUID、算力等详细规格。HAMi 的解决方案是额外将这些信息写入 Node Annotation,供 hami-scheduler 读取:
| Annotation | 用途 |
|---|---|
hami.io/node-nvidia-register |