找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3853

积分

0

好友

566

主题
发表于 5 小时前 | 查看: 5| 回复: 0

在 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(Heterogeneous AI Computing Virtualization Middleware)走了另一条路:不改驱动、不改应用,通过 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 之于 PV
  • DeviceClass:描述一类设备的规格和筛选条件,支持 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.sold.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 设备规格列表(UUID、虚报数量、显存、算力、型号、NUMA 节点、健康状态)

滑动查看

字段格式(JSON 数组,每块 GPU 一个对象):

# 2 块 32G V100 节点的示例
hami.io/node-nvidia-register: '[
  {"id":"GPU-00552014-...","count":10,"devmem":32768,"devcore":100,"type":"NVIDIA-Tesla V100-PCIE-32GB","numa":0,"health":true},
  {"id":"GPU-0fc3eda5-...","count":10,"devmem":32768,"devcore":100,"type":"NVIDIA-Tesla V100-PCIE-32GB","numa":0,"health":true}
]'

各字段说明:id 为 GPU UUID,count 为虚报逻辑数量,devmem 为显存(MiB),devcore 为算力(%),numa 为 NUMA 节点编号,health 为健康状态。

hami-scheduler 启动后持续 Watch 所有 GPU 节点的这个 Annotation,维护全局 GPU 资源视图。

第二步:调度决策

用户提交 Pod 时声明细粒度资源需求:

resources:
  limits:
    nvidia.com/gpu: 1            # 申请 1 个逻辑 GPU 槽位
    nvidia.com/gpumem: 1024      # 申请 1024 MiB 显存
    nvidia.com/gpucores: 30      # 申请 30% 算力(可选)

hami-scheduler 作为 kube-scheduler 的扩展器,在标准调度流程的 FilterBind 阶段介入:

  • Filter:读取 Node Annotation,检查目标节点是否有足够剩余显存(已分配显存之和 + 本次请求 ≤ 物理显存总量
  • Bind:选定具体物理 GPU UUID,将分配结果写入 Pod Annotation

调度结果通过 Pod Annotation 传递给 Device Plugin:

标准 Kubernetes 调度流程中,kube-scheduler 在 Bind 阶段仅向 device-plugin 传递设备 UUID,不携带显存量、算力等信息。因此 HAMI 约定了以下 Pod Annotation 作为调度器与 device-plugin 之间的通信协议:

Annotation 内容
hami.io/bind-time 调度时间戳(Unix 时间),device-plugin 用于超时检测
hami.io/vgpu-devices-allocated 已分配的设备列表(UUID + 厂商 + 显存 MiB + 算力%),完成后保留作为记录
hami.io/vgpu-devices-to-allocate 待分配设备列表,初始与 allocated 相同;device-plugin 每挂载成功一个设备就移除对应条目,全部移除后置空,表示分配完成

滑动查看

以申请 3000 MiB 显存的 Pod 为例,运行成功后的 Annotation:

hami.io/bind-time: 1716199325
hami.io/vgpu-devices-allocated: GPU-0fc3eda5-e98b-a25b-5b0d-cf5c855d1448,NVIDIA,3000,0:;
hami.io/vgpu-devices-to-allocate: ;    ← 已为空,说明设备分配完成

第三步:设备注入与库劫持

Pod 调度到目标节点后,Kubelet 调用 Device Plugin 的 Allocate 接口,Device Plugin 在响应中完成四件事:

  1. 挂载设备文件:将 /dev/nvidia* 等设备文件注入容器
  2. hostPath 挂载 libvgpu.so:将宿主机上的 libvgpu.so(默认路径 /usr/local/vgpu/libvgpu.so)以 hostPath 方式挂载到容器内同路径
  3. hostPath 挂载 ld.so.preload:将宿主机上的 /usr/local/vgpu/ld.so.preload 挂载到容器内的 /etc/ld.so.preload。该文件内容只有一行:/usr/local/vgpu/libvgpu.so。Linux 动态链接器在容器内任何进程启动时都会读取 /etc/ld.so.preload,并将其中列出的库最先加载,效果等同于 LD_PRELOAD,但无需修改任何环境变量,对容器内所有进程透明生效。若容器设置了 CUDA_DISABLE_CONTROL=true,则跳过此挂载,禁用隔离
  4. 注入环境变量
    • CUDA_DEVICE_MEMORY_LIMIT_<index>=<数字>m:per-device 显存配额,index 为容器内设备索引(0、1、2...),值带单位后缀 m(如 1024m),来自 Pod 申请的 nvidia.com/gpumem
    • CUDA_DEVICE_SM_LIMIT=<百分比>:算力配额上限(来自 Pod 申请的 nvidia.com/gpucores

容器启动后,libvgpu.so 通过重写 dlsym 函数劫持 NVIDIA 动态库的符号解析,对所有以 cunvml 开头的函数调用进行拦截:

显存限制(Memory Limit):

  • 拦截 nvmlDeviceGetMemoryInfo / nvmlDeviceGetMemoryInfo_v2:使 nvidia-smi 只显示 CUDA_DEVICE_MEMORY_LIMIT_<index> 设定的配额值,而非物理总显存
  • 拦截 cuMemAlloc_v2 / cuMemAllocManaged / cuMemAllocHost_v2 等内存分配函数:分配前执行 OOM 检查:若当前 Pod 已用显存 + 本次申请量 > CUDA_DEVICE_MEMORY_LIMIT_<index>,直接返回 CUDA_ERROR_OUT_OF_MEMORY,阻止超额分配

算力限制(Core Limit):

  • 拦截 cuLaunchKernel / cuLaunchKernelEx 等 Kernel 提交函数:每次提交前调用 rate_limiter,以本次 kernel 的 grid 数量为单位消耗全局计数器 g_cur_cuda_cores;当计数器耗尽(< 0)时,当前调用进入自旋等待(nanosleep
  • 计数器的补充由后台 utilization watcher 线程负责:该线程定期采样实际 GPU 利用率,通过 delta() 函数动态计算补充量,利用率低于配额时快速补充,高于配额时减少补充,从而将容器的整体算力占用收敛到 CUDA_DEVICE_SM_LIMIT 设定的百分比以内

至此,一个 GPU Pod 的完整生命周期走完了:Webhook 负责入口,Scheduler Extender 负责选卡,Device Plugin 负责注入,HAMi-Core 负责在容器内守住边界。

以下是在真实集群上的验证结果。

验证结果

集群环境:

GPU:NVIDIA GeForce RTX 3060(12288 MiB)
CUDA Driver:550.120
HAMI 版本:v2.8.0

运行组件状态:

NAME                              READY   STATUS    RESTARTS   AGE
hami-device-plugin-wtchf          2/2     Running   0          16h
hami-scheduler-78db5bffbc-hf2jf   2/2     Running   0          16h

节点资源上报:

# GPU 虚报为 10 个逻辑资源
nvidia.com/gpu: "10"

# 物理 GPU 详细信息(Annotation)
hami.io/node-nvidia-register: '[{
  "id": "GPU-32f85ff6-efb5-a95e-2d82-ce615ca43152",
  "count": 10,
  "devmem": 12288,
  "devcore": 100,
  "type": "NVIDIA GeForce RTX 3060",
  "numa": 0,
  "health": true
}]'

多 Pod 并发测试(10 个 Pod 同时共享 1 块 GPU):

测试 YAML(Job,10 个并发实例,每个申请 1024 MiB 显存):

apiVersion: batch/v1
kind: Job
metadata:
  name: gpu-test-10
spec:
  completions: 10
  parallelism: 10
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: gpu-test
          image: docker.io/nvidia/cuda:12.4.0-base-ubuntu22.04
          command:
            - bash
            - -c
            - |
              echo "=== Pod: $HOSTNAME ==="
              nvidia-smi
              sleep 3600
          resources:
            limits:
              nvidia.com/gpu: 1
              nvidia.com/gpumem: 1024   # 每个 Pod 申请 1024 MiB

10 个 Pod 全部成功调度运行:

NAME                READY   STATUS    RESTARTS   AGE
gpu-test-10-5rdvl   1/1     Running   0          10m
gpu-test-10-cpp7n   1/1     Running   0          10m
gpu-test-10-f48b5   1/1     Running   0          10m
gpu-test-10-kmnr6   1/1     Running   0          10m
gpu-test-10-lwkk6   1/1     Running   0          10m
gpu-test-10-qpjhn   1/1     Running   0          10m
gpu-test-10-qwqzc   1/1     Running   0          10m
gpu-test-10-s8bkd   1/1     Running   0          10m
gpu-test-10-t8thz   1/1     Running   0          10m
gpu-test-10-zghzv   1/1     Running   0          10m

容器内 nvidia-smi 输出(显存隔离验证):

Tue Mar 10 02:36:50 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.120          Driver Version: 550.120        CUDA Version: 12.4           |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 3060        Off |   00000000:84:00.0 Off |                  N/A |
|  0%   33C    P8             13W /  170W |       0MiB /   1024MiB |      0%      Default |
+-----------------------------------------+------------------------+----------------------+

关键观察:容器内看到的 GPU 总显存为 1024 MiB(而非物理的 12288 MiB),显存隔离生效。10 个 Pod × 1024 MiB = 10240 MiB < 12288 MiB,满足总量约束。

HAMI 调度策略详解

HAMI 的调度策略分两个独立维度:节点选哪个node-scheduler-policy)和节点内选哪张卡gpu-scheduler-policy),每个维度都可配置 binpack(集中)或 spread(分散)。全局默认在 values.yaml 中设置,也可通过 Pod Annotation 按需覆盖:

# Helm 全局配置
scheduler:
  defaultSchedulerPolicy:
    nodeSchedulerPolicy: binpack
    gpuSchedulerPolicy: spread

# Pod 级别覆盖
metadata:
  annotations:
    hami.io/node-scheduler-policy: "spread"
    hami.io/gpu-scheduler-policy: "binpack"

节点调度策略

调度器对每个节点计算综合得分(基于当前已用资源,不含本次申请量):

节点得分 = (已用设备数/总设备数 + 已用算力/总算力 + 已用显存/总显存) × 10

假设集群有 2 个节点,各有 4 块 GPU,每块总算力 100%、总显存 8192 MiB(节点总计:算力 400%、显存 32768 MiB)。节点1 已用 3 块、已用算力 240%、已用显存 20480 MiB;节点2 已用 2 块、已用算力 120%、已用显存 8192 MiB:

节点1 得分 = (3/4 + 240/400 + 20480/32768) × 10 = 19.75
节点2 得分 = (2/4 + 120/400 + 8192/32768)  × 10 = 10.50

Binpack 取高分节点,优先填满负载更重的节点,为空节点留出完整资源,适合想腾出整机给训练任务的场景,两个 Pod 都会调度到节点1。Spread 取低分节点,将任务分散开,适合在线推理横向扩展,pod1 调度到节点2,pod2 调度到节点1。

GPU 卡调度策略

同一节点内,调度器对每张卡计算得分(含本次申请量):

GPU 得分 = ((申请数量 + 已用数量) / 总槽位 + (申请 core + 已用 core) / 总 core + (申请 mem + 已用 mem) / 总 mem) × 10

假设节点内 GPU1 已占用 2 个槽位、core 10%、mem 2000 MiB,GPU2 已占用 6 个槽位、core 70%、mem 6000 MiB,总槽位 10、总显存 8000 MiB,本次申请 1 个槽位、core 20%、mem 1000 MiB:

GPU1 得分 = ((1+2)/10 + (20+10)/100 + (1000+2000)/8000) × 10 = 9.75
GPU2 得分 = ((1+6)/10 + (20+70)/100 + (1000+6000)/8000) × 10 = 24.75

Binpack 取高分卡(GPU2),将多个 Pod 塞进同一张已有负载的卡,让 GPU1 空出来给独占型任务。Spread 取低分卡(GPU1),降低单卡竞争压力,适合延迟敏感的推理服务。

两个维度正交组合,常见搭配:

节点策略 GPU 策略 典型场景
Binpack Binpack 资源整合,腾出整机给大型任务
Binpack Spread 节点集中,卡间隔离
Spread Binpack 跨节点高可用,节点内集中
Spread Spread 全方位打散,最大容错

滑动查看

总结

HAMI 的完整链路:Webhook 将 Pod 引入 hami-scheduler,Scheduler Extender 依据 Node Annotation 中的 GPU 规格做感知调度并将分配结果写回 Pod Annotation,Device Plugin 在 Allocate 时读取 Annotation、注入 libvgpu.so(即 HAMi-Core)和限额环境变量,最终由 HAMi-Core 在容器内拦截 CUDA API 完成软隔离。

HAMI 的调度侧相对轻量,适合AI推理这类单任务独立调度的场景。如果是 AI 训练场景,需要 Gang Scheduling(多 Pod 全部就绪才开始)、队列管理、公平份额等批处理能力,可以考虑 Volcano 配合 volcano-vgpu-device-plugin,两者的组合在 GPU 虚拟化基础上补齐了批调度能力。对于追求极致性能和数据中心级硬件的场景,MIG 硬件分区仍是重要的参考方案。




上一篇:那个越狱iPhone的天才黑客,现在要用1.5万美元的TinyBox挑战英伟达
下一篇:容器化原理深度剖析:Go调用Linux内核API实现Namespace、Cgroups及UnionFS
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-8 08:57 , Processed in 0.580431 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表