HAMi 让多个 Pod 共享同一张物理 GPU,按显存和算力精细分配。本文从源码角度拆解这条链路:Pod 提交 → Webhook 准入 → 调度器选节点选设备 → 设备插件注入隔离层 → 容器内 CUDA API 劫持。

一张 GPU 怎么分给多个 Pod

先用一个具体例子建立直觉。

节点上有一张 NVIDIA A100(80GB 显存),DeviceSplitCount=10,意味着 HAMi 把这张 GPU 注册为 10 个”虚拟设备”给 kubelet。现在两个 Pod 先后来申请资源:

  • Pod A 请求 1 GPU、30GB 显存、50% 算力
  • Pod B 请求 1 GPU、20GB 显存、30% 算力

调度器在内存中维护每个设备的用量账本(DeviceUsage):

ounter(lineounter(lineounter(lineounter(lineounter(line
DeviceUsage {
  Totalmem: 81920,  Usedmem: 0      → 分配 Pod A 后 Usedmem=30720
  Totalcore: 100,   Usedcores: 0    → 分配 Pod A 后 Usedcores=50
  Count: 10,        Used: 0         → 分配 Pod A 后 Used=1
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

Pod A 通过检查(剩余显存 51200 ≥ 30720,剩余算力 50 ≥ 50,分片数 9 > 0),分配成功。Pod B 到来时,剩余显存 51200 ≥ 20480,剩余算力 50 ≥ 30,分片数 8 > 0,也通过。

但光在调度器里记账不够——容器启动后,Pod A 的进程凭什么只用 30GB 显存?这就是 HAMi 的两层机制:「调度器做资源记账和分配决策,容器内通过 libvgpu.so 劫持 CUDA API 做硬限制」。

四个组件怎么协作

ounter(lineounter(lineounter(lineounter(lineounter(line
Pod 提交
  → [1] MutatingWebhook:拦截 Pod,注入调度器名,校验资源请求
  → [2] HAMi Scheduler:选节点 + 选设备,把分配结果写入 Pod annotation
  → [3] DevicePlugin:读取 annotation,注入环境变量 + 挂载 libvgpu.so
  → [4] libvgpu.so:容器启动后劫持 CUDA 调用,执行显存/算力限制
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

下面逐层拆解。

1. MutatingWebhook:准入拦截

「入口」:pkg/scheduler/webhook.go:53Handle()

Webhook 拦截所有 Pod 创建请求,做三件事:

「a) 资源请求规范化」

调用每个设备厂商的 MutateAdmission()(pkg/device/nvidia/device.go:328)。NVIDIA 的实现包括:

  • 验证显存百分比(0-100)
  • 如果容器只请求了 nvidia.com/gpumem 但没请求 nvidia.com/gpu,自动补上 count 资源
  • 如果请求了 100% 显存但没指定算力,自动设为 100 核(独占语义)

「b) 注入调度器名」

如果 Pod 请求了 HAMi 管理的资源,将 pod.Spec.SchedulerName 设为 "hami-scheduler"(webhook.go:107)。这样 kube-scheduler 才会把 Pod 转发给 HAMi 的 extender。

「c) 安全检查」

特权容器不允许使用 GPU(webhook.go:99)。因为 libvgpu.so 的拦截是协作式的,特权容器可以绕过 LD_PRELOAD,所以直接在准入阶段拒绝。

2. HAMi Scheduler:设备感知调度

「入口」:pkg/scheduler/scheduler.go:1009Filter()

HAMi 以 Kubernetes Scheduler Extender 的方式运行,注册了 /filter 和 /bind 两个 HTTP 路由。「关键设计:Filter 阶段同时完成 filter + score + node selection,直接返回单个节点。」

2.1 设备资源从哪来

设备插件在每个节点上运行,通过 NVML 获取 GPU 信息,每 30 秒将设备规格写入节点 annotation:

ounter(line
hami.io/node-nvidia-register: GPU-xxx,10,81920,100,NVIDIA-A100-80GB,0,true:...
  • 1.
  • 2.

格式:{UUID},{可分片数},{显存MB},{算力%},{型号},{NUMA},{健康}

调度器的 RegisterFromNodeAnnotations()(scheduler.go:404)持续同步这些信息到内存缓存 nodeManager。

2.2 资源请求解析

device.Resourcereqs()(devices.go:636)遍历 Pod 的所有容器,调用每个厂商的 GenerateResourceRequests()(nvidia/device.go:501),从 Resources.Limits 中解析出:

K8s 资源名 含义 解析结果
nvidia.com/gpu GPU 数量 Nums
nvidia.com/gpumem 显存(MB) Memreq
nvidia.com/gpucores 算力(%) Coresreq
nvidia.com/gpumem-percentage 显存百分比 MemPercentagereq

2.3 节点使用快照

getNodesUsage()(scheduler.go:720)构建调度时刻的设备用量快照:

  1. 从 nodeManager 读取所有节点的设备注册信息,初始化 DeviceUsage(Used=0, Usedmem=0, Usedcores=0)
  2. 遍历 podManager 中所有已分配 Pod,累加使用量:

d.Device.Used++              // 分片数+1
d.Device.Usedmem += udevice.Usedmem   // 显存累加
d.Device.Usedcores += udevice.Usedcores  // 算力累加
  • 1.
  • 2.
  • 3.
  1. 返回快照供评分使用

2.4 评分与设备匹配

calcScore()(score.go:342)对每个节点并发评分。核心是 scoreNode()(score.go:276):

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
scoreNode():
  1. 为 init 容器分配设备(在 node 副本上模拟)
  2. 为 app 容器分配设备(在另一个 node 副本上模拟)
  3. 合并:init 容器顺序执行,app 容器并行执行
     → 有效使用量 = max(init 峰值, app 总和)
  4. 计算评分
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

设备级匹配在 fitInDevices()(score.go:63)中调用厂商的 Fit() 方法。

2.5 NVIDIA Fit:逐设备检查

「pkg/device/nvidia/device.go:670」Fit() 是设备共享调度的核心。遍历节点上的每张 GPU,按以下顺序检查:

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
对每张 GPU:
  ① 健康检查        → dev.Health == true
  ② 型号匹配        → checkType() 匹配 annotation 中的型号要求
  ③ UUID 过滤       → 支持 use-gpuuuid / nouse-gpuuuid
  ④ 分片数检查      → dev.Count > dev.Used(共享份数未用完)
  ⑤ 互斥策略检查    → 如果 policy=mutex,dev.Used==0 才可用
  ⑥ 显存检查        → dev.Totalmem - dev.Usedmem >= memreq
  ⑦ 算力检查        → dev.Totalcore - dev.Usedcores >= coresreq
  ⑧ 独占检查        → coresreq=100 且 dev.Used>0 → 跳过
  ⑨ MIG 检查        → CustomFilterRule() 检查 profile 和 placement
  ⑩ 配额检查        → fitQuota() 检查 namespace 级配额
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

全部通过则分配成功,调用 AddResourceUsage() 累加用量。

2.6 调度策略

两级调度策略,可通过 Pod annotation 覆盖:

层级 策略 行为 配置
节点级 binpack

(默认)

优先选使用率高的节点 --node-scheduler-policy
节点级 spread 优先选使用率低的节点
设备级 spread

(默认)

优先选空闲 GPU --gpu-scheduler-policy
设备级 binpack 优先选已占用的 GPU
设备级 mutex 只选完全空闲的 GPU
设备级 topology-aware 基于 GPU 间 P2P 拓扑选最优组合

2.7 分配结果写入 Pod annotation

调度器调用 PatchAnnotations()(nvidia/device.go:486)将分配结果写入 Pod annotation:

ounter(lineounter(lineounter(line
hami.io/vgpu-devices-to-allocate: GPU-xxx,NVIDIA,3000,50:;GPU-yyy,NVIDIA,2000,30:;
hami.io/vgpu-node: node-1
hami.io/vgpu-time: 1705054796
  • 1.
  • 2.
  • 3.
  • 4.

格式:{UUID},{类型},{显存MB},{算力%}: 每个设备一组,; 分隔容器。

2.8 Bind 阶段

Bind()(scheduler.go:940)不重新计算分配,只做两件事:

  1. 加节点锁(通过 annotation hami.io/mutex.lock 实现互斥,防止并发调度冲突)
  2. 调用 K8s API 绑定 Pod 到 Node

3. DevicePlugin:注入隔离层

「入口」:pkg/device-plugin/nvidiadevice/nvinternal/plugin/server.go:737Allocate()

kubelet 在创建容器前调用 DevicePlugin 的 Allocate(),HAMi 的设备插件在这里完成容器层面的资源隔离。

3.1 读取调度决策

decodePodSingleDevice() 从 Pod annotation 解析出设备分配信息。popNextContainerDevices()(util.go:112)按顺序弹出每个容器的设备——annotation 中 init 容器在前,app 容器在后。

3.2 注入环境变量

ounter(lineounter(lineounter(lineounter(line
// server.go:822-837
response.Envs["CUDA_DEVICE_MEMORY_LIMIT_0"] = "3000m"  // 显存上限
response.Envs["CUDA_DEVICE_SM_LIMIT"] = "50"           // SM 利用率限制
response.Envs["CUDA_DEVICE_MEMORY_SHARED_CACHE"] = "/usr/local/vgpu/xxx.cache"
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
环境变量 作用
CUDA_DEVICE_MEMORY_LIMIT_{i} 第 i 个设备的显存上限(MB)
CUDA_DEVICE_SM_LIMIT SM 利用率限制(0-100)
CUDA_DEVICE_MEMORY_SHARED_CACHE 共享内存缓存文件路径
CUDA_OVERSUBSCRIBE 超售开关(DeviceMemoryScaling > 1 时开启)
GPU_CORE_UTILIZATION_POLICY 算力限制策略(default/force/disable)

3.3 挂载 libvgpu.so

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
// server.go:845-873
// 挂载 libvgpu.so 到容器
response.Mounts = append(response.Mounts, &Mount{
    ContainerPath: "/usr/local/vgpu/libvgpu.so",
    HostPath:      hostHookPath + "/vgpu/libvgpu.so",
    ReadOnly:      true,
})


// 关键:通过 ld.so.preload 劫持所有 CUDA 调用
response.Mounts = append(response.Mounts, &Mount{
    ContainerPath: "/etc/ld.so.preload",
    HostPath:      hostHookPath + "/vgpu/ld.so.preload",
    ReadOnly:      true,
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

ld.so.preload 文件内容为 /usr/local/vgpu/libvgpu.so。Linux 动态链接器在加载任何程序时,会先加载 ld.so.preload 中列出的共享库。这样 libvgpu.so 就被注入到容器内所有进程。

3.4 设备副本机制

设备插件向 kubelet 注册设备时,每张物理 GPU 被报告为 DeviceSplitCount 个独立设备(rm/devices.go:194GetPluginDevices())。ID 格式为 {原始UUID}-{副本序号},如 GPU-xxx-0, GPU-xxx-1。kubelet 将它们视为独立资源进行分配。

4. libvgpu.so:容器内 CUDA API 劫持

libvgpu.so 由 HAMi-core 项目编译,是设备共享在容器层面的执行者。

4.1 工作原理

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
容器启动
  → ld.so 读取 /etc/ld.so.preload
  → 预加载 libvgpu.so
  → libvgpu.so 读取环境变量获取限制值
  → 拦截所有 CUDA API 调用:
      cudaMalloc → 检查显存是否超限
      kernel launch → 检查 SM 利用率是否超限
  → 多容器通过共享内存区域协调资源使用
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

4.2 共享内存协调

pkg/monitor/nvidia/v1/spec.go:57 定义了共享内存区域格式 sharedRegionT:

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
type sharedRegionT struct {
    initializedFlag int32       // 初始化标志
    num             uint64      // 设备数量
    uuids           [16]uuid    // 设备 UUID 列表
    limit           [16]uint64  // 每个设备的显存上限
    smLimit         [16]uint64  // 每个设备的 SM 上限
    procs           [1024]shrregProcSlotT  // 进程槽位
    procnum         int32       // 活跃进程数
    // ...
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

多个容器共享同一物理 GPU 时,通过共享内存区域协调各自的资源使用量。/tmp/vgpulock 提供进程间锁,确保共享内存操作的原子性。

4.3 安全边界

libvgpu.so 的拦截是「协作式」的,不是安全边界。有特权的容器可以通过取消 LD_PRELOAD 绕过限制。这也是 Webhook 拒绝特权容器使用 GPU 的原因。对于不可信工作负载,应使用 MIG 硬件隔离。

三种共享模式对比

NVIDIA 设备支持三种虚拟化模式(nvidia/device.go:54):

维度 hami-core(软隔离) MIG(硬件隔离) MPS
「隔离机制」 CUDA API 劫持 GPU 硬件分区 NVIDIA MPS 服务
「隔离强度」 弱(可绕过) 强(硬件级) 中
「显存隔离」 环境变量 + libvgpu.so 硬件独立显存 MPS 共享
「算力隔离」 SM 利用率限制 硬件独立 SM MPS 调度
「切分粒度」 任意显存 + 0-100% 算力 固定 profile(1g.5gb 等) —
「支持 GPU」 所有 NVIDIA GPU 仅 A100/H100 等 支持 MPS 的 GPU
「超售」 支持 不支持 —
「故障隔离」 无 有 无

MIG 切分的关键逻辑

MIG 模式下,调度器从 MigProfiles 中选择合适的 profile 和 placement(nvidia/device.go:598selectMigCandidate()):

  • 按 MemoryMB 升序排序,优先选最小的能满足需求的 profile
  • 检查 placement 是否与已占用的位置冲突(mig_topology.go:78migPlacementsOverlap())
  • 分配后修正 Usedmem 和 Usedcores 为 profile 实际值(不是请求值)

init 容器的资源折叠

init 容器和 app 容器的资源使用模式不同:init 容器顺序执行,app 容器并行执行。HAMi 在调度时取 max(init 峰值, app 总和) 作为有效使用量(initContainer.go:35CollapseInitContainerUsage())。

当所有 init 容器成功完成后,调度器检测到 AllInitContainersSucceeded(scheduler.go:204),将 Pod 的设备使用量从”折叠后的峰值”收缩为”仅 app 容器的使用量”,释放多余的资源配额。

配额管理

QuotaManager(quota.go:27)维护 namespace 级别的设备资源配额:

  • 监听 K8s ResourceQuota 对象变化,读取 limits. 前缀的配额项
  • FitQuota()(quota.go:62)检查 Used + memreq > Limit → 拒绝
  • LimitSet 字段区分”显式限制为 0″和”未设置限制”
  • MemoryFactor 处理不同厂商的显存单位差异(如寒武纪 MLU 以 256 MiB 为单位)

异构设备统一管理

所有厂商设备实现统一的 Devices 接口(devices.go:34),调度器代码完全统一:

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
type Devices interface {
    GenerateResourceRequests(ctr *corev1.Container) ContainerDeviceRequest
    GetNodeDevices(n corev1.Node) ([]*DeviceInfo, error)
    Fit(devices []*DeviceUsage, request ContainerDeviceRequest, ...) (bool, map[string]ContainerDevices, string)
    AddResourceUsage(pod *corev1.Pod, n *DeviceUsage, ctr *ContainerDevice) error
    PatchAnnotations(pod *corev1.Pod, annoinput *map[string]string, pd PodDevices) map[string]string
    // ...
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

调度时通过 device.GetDevices()[k.Type] 查找对应厂商的实现,调用 Fit() 进行匹配。当前支持 15+ 种设备:NVIDIA、AMD、寒武纪、昇腾、海光 DCU、燧原、摩尔线程、MetaX、昆仑、壁仞、天数智芯、AWS Neuron 等。

 

文章来自:51CTO

Loading

yinhua
Author: yinhua

作者 yinhua

发表回复