导读:在 Kubernetes 集群中运行 GPU 应用时,可以解决 AI 训练等场景中申请独立卡造成资源浪费的情况,让计算资源得到充分利用。


一、容器 GPU 虚拟化


首先,我们这里谈到的,都是 nVidia 生产的 GPU、都只考虑 CUDA 计算场景。其次,这里的虚拟化指的是 OS 虚拟化的容器技术,不适用于 KATA 这样的、基于系统虚拟化的安全容器。

CUDA 的生态

GPU 虚拟化实践_API

CUDA 开发者使用的,通常是 CUDA Runtime API,它是 high-level 的;而 CUDA Driver API 则是 low-level 的,它对程序和 GPU 硬件有更精细的控制。Runtime API 是对 Driver API 的封装。

CUDA Driver 即是 UMD,它直接和 KMD 打交道。两者都属于 NVIDIA Driver package,它们之间的 ABI,是 NVIDIA Driver package 内部的,不对外公开。

英伟达软件生态封闭:

  • 无论是 nvidia.ko,还是 libcuda.so,还是 libcudart,都是被剥离了符号表的
  • 大多数函数名是加密替换了的
  • 其它的反调试、反逆向手段

vCUDA 和友商 cGPU

为了让多个容器可以共享同一个 GPU,为了限定每个容器能使用的 GPU 份额,业界出现了不同的方案,典型的如 vCUDA 和 cGPU:

vCUDA 架构:

GPU 虚拟化实践_Pod_02

cGPU 架构:

GPU 虚拟化实践_Pod_03

两者的实现策略不同,cGPU 比 vCUDA 更底层,从而实现了不侵入用户环境。

GPU 池化简介

从截获的位置,看 GPU 池化的谱系:

GPU 虚拟化实践_CUDA_04

以 CUDA API 转发的池化方案、业界某产品为例,它到了 GPU 所在的后端机器上,由于一个 GPU 卡可能运行多个 GPU 任务,这些任务之间,依然需要有算力隔离。它为了实现这一点,在后端默认启用了 nVidia MPS —— 也就是故障隔离最差的方案。这会导致什么?一个 VM 里的 CUDA 程序越界访问了显存,一堆风马牛不相及的 VM 里的 CUDA 应用就会被杀死

所以,很显然,GPU 池化也必须以同时满足故障隔离和算力隔离的方案作为基础。

二、共享 GPU 方案

阿里 cGPU

来自阿里的cGPU(container GPU)[1]是最早提出的通过内核劫持来实现容器级GPU共享的方案。cGPU实现了一个内核模块cgpu_km,该模块可以对一个物理GPU虚拟出16个虚拟GPU设备。在容器挂载设备时,修改后的container runtime将挂载虚拟GPU设备,而不是真实GPU设备。通过这种方式实现了GPU劫持。当用户程序的请求下发至内核模块cgpu_km时,模块通过修改请求及回复来限制GPU显存资源。同时,内核模块也实现了简单的算力调度,通过限制每个容器可下发kernel的时间片来隔离算力资源。可以提供公平/抢占/权重三种算力分配模式。值得注意的是,cGPU目前不能中止已经发送到GPU上的请求,因此如追求算力隔离,需要延长时间片的长度,会造成一定的算力浪费。出于某些考虑未有开源。

既然是容器级的GPU共享,接入到K8s的组件是必不可少的。阿里开源了相应的device plugin[3]和调度器[2]。设计的device plugin提供的核心资源是显存,这和cGPU是一脉相承的。另外由于当前K8s支持的资源类型是一维的,而GPU共享资源是二维的。为了实现调度能力,应用了一些tricky 的技巧,也让device plugin不得不和APIServer直接通信。


腾讯 GaiaGPU


腾讯提供了一整套GPU共享解决方案GaiaGPU[4],是完全开源的GPU共享方案,salute。GaiaGPU中的vCUDA(virtual CUDA)[5]是GPU资源限制组件,属于CUDA劫持。vCUDA通过劫持CUDA的显存申请和释放请求,为每个容器管理它的显存使用量,进而实现了显存隔离。唯一需要注意的是申请context并不通过malloc函数,因此无法知道进程在context使用了多少显存。因此vcuda每次都去向GPU查询当前的显存使用量。在算力隔离方面,使用者可以指定容器的GPU利用率。vCUDA将会监控利用率,并在超出限制利用率时做一些处理。此处可以支持硬隔离和软隔离。两者的不同点是,如果有资源空闲,软隔离允许任务超过设置,而硬隔离不允许。由于使用的是监控调节[22]的方案,因此无法在短时间内限制算力,只能保证长时间的效率公平。所以不适合推理等任务时间极短的场景。

GaiaGPU也提供了Device plugin GPU manager[6]和调度器 GPU admission[7],GPU admission既允许用户申请一张虚拟卡,也允许用户像之前一样申请一机多卡,这可能可以满足一些小型集群的需要。GPU manager除实现了device plugin该实现的,也做了很多繁杂的功能,使得apiserver的负担更重了。


腾讯 qGPU

腾讯在内核劫持类GPU共享方向上,也推出了资源隔离方案qGPU(qos GPU)[8]。从架构图中就可以看出,qGPU和同属于内核劫持方案的cGPU类似。但值得注意的是,qGPU效仿Nvidia vGPU在必要时context switch,实现了强算力隔离,这也是其名字的由来。出于某些考虑未有开源。


第四范式 OpenAIOS vGPU

第四范式的GPU共享方案还叫vGPU[13],也是CUDA劫持方案。由于没有开源资源隔离部分的代码,从文档中推测,其实现和GaiaGPU的vcuda较为类似:显存隔离使用的是经典CUDA劫持方法,通过预估获得context大小;使用监控隔离的方案隔离算力。同样地,方案的优缺点也和vCUDA类似。较为特别的一点是,和阿里Antman[18]相同地,第四范式vGPU通过Nvidia UVM实现了虚拟显存。不过UVM实质上是使用内存来虚拟显存,因此会消耗较大的内存,且性能会有较大下降。若要使用虚拟显存功能,还需思考程序本身占用的内存和虚拟显存的trade off。

第四范式开源了device plugin[14],使用了和nvidia device plugin中处理MIG设备一样的思路,将节点上所有虚拟GPU设备设定为同一大小。这丧失了一定的用户自由,但对大型集群来说,这样做更通用且更容易维护。同时,采用这种方案不需重新设计调度器。



GPU 虚拟化实践_API_05

三、Kubernetes 共享 GPU 集群调度

前提

  1. 依旧延用 Kubernetes Extended Resource 定义,但是衡量维度最小单位从 1 个 GPU 卡变为 GPU 显存的 MiB。如果所节点使用的 GPU 为单卡 16GiB 显存,它对应的资源就是 16276MiB;
  2. 由于用户对于共享 GPU 的诉求在于模型开发和模型预测场景,在此场景下,用户申请的GPU资源上限不会超过一张卡,也就是申请的资源上限为单卡。


而我们的工作首先是定义了两个新的 Extended Resource: 第一个是 gpu-mem,对应的是 GPU 显存;第二个是 gpu-count,对应的是 GPU 卡数。 通过两个标量资源描述矢量资源,并且结合这一资源,提供支持共享 GPU 的工作机制。下面是基本的架构图:


GPU 虚拟化实践_API_06


核心功能模块

  • GPU Share Scheduler Extender: 利用 Kubernetes 的调度器扩展机制,负责在全局调度器 Filter 和 Bind 的时候判断节点上单个 GPU 卡是否能够提供足够的 GPU Mem,并且在 Bind 的时刻将 GPU 的分配结果通过 annotation 记录到 Pod Spec 以供后续 Filter 检查分配结果。
  • GPU Share Device Plugin: 利用 Device Plugin 机制,在节点上被 Kubelet 调用负责 GPU 卡的分配,依赖 scheduler Extender 分配结果执行。


1. 资源上报

GPU Share Device Plugin 利用 nvml 库查询到 GPU 卡的数量和每张 GPU 卡的显存, 通过ListAndWatch()将节点的 GPU 总显存(数量 * 显存)作为另外 Extended Resource 汇报给 Kubelet; Kubelet 进一步汇报给 Kubernetes API Server。 举例说明,如果节点含有两块 GPU 卡,并且每块卡包含 16276MiB,从用户的角度来看:该节点的 GPU 资源为 16276 * 2 = 32552; 同时也会将节点上的 GPU 卡数量 2 作为另外一个 Extended Resource 上报。

2. 扩展调度

GPU Share Scheduler Extender 可以在分配 gpu-mem 给 Pod 的同时将分配信息以 annotation 的形式保留在 Pod spec 中,并且在过滤时刻根据此信息判断每张卡是否包含足够可用的 gpu-mem 分配。 Kubernetes 默认调度器在进行完所有过滤(filter)行为后会通过 http 方式调用 GPU Share Scheduler Extender 的 filter 方法, 这是由于默认调度器计算 Extended Resource 时,只能判断资源总量是否有满足需求的空闲资源,无法具体判断单张卡上是否满足需求;所以就需要由 GPU Share Scheduler Extender 检查单张卡上是否含有可用资源。 以下图为例, 在由 3 个包含两块 GPU 卡的节点组成的 Kubernetes 集群中,当用户申请gpu-mem=8138时,默认调度器会扫描所有节点,发现 N1 所剩的资源为 (16276 * 2 - 16276 -12207 = 4069 )不满足资源需求,N1 节点被过滤掉。 而 N2 和 N3 节点所剩资源都为 8138MiB,从整体调度的角度看,都符合默认调度器的条件;此时默认调度器会委托 GPU Share Scheduler Extender 进行二次过滤,在二次过滤中,GPU Share Scheduler Extender 需要判断单张卡是否满足调度需求,在查看 N2 节点时发现该节点虽然有 8138MiB 可用资源,但是落到每张卡上看,GPU0 和分别 GPU1 只有 4069MiB 的可用资源,无法满足单卡 8138MiB 的诉求。


而 N3 节点虽然也是总共有 8138MiB 可用资源,但是这些可用资源都属于 GPU0,满足单卡可调度的需求。由此,通过 GPU Share Scheduler Extender 的筛选就可以实现精准的条件筛选。


GPU 虚拟化实践_API_07


2.2 当调度器找到满足条件的节点,就会委托 GPU Share Scheduler Extender 的 bind 方法进行节点和 Pod 的绑定,这里 Extender 需要做的是两件事情:

  • 以 binpack 的规则找到节点中最优选择的 GPU 卡 id,此处的最优含义是对于同一个节点不同的 GPU 卡,以 binpack 的原则作为判断条件,优先选择空闲资源满足条件但同时又是所剩资源最少的 GPU 卡,并且将其作为ALIYUN_COM_GPU_MEM_IDX保存到 Pod 的 annotation 中;同时也保存该 Pod 申请的 GPU Memory 作为ALIYUN_COM_GPU_MEM_PODALIYUN_COM_GPU_MEM_ASSUME_TIME保存至 Pod 的 annotation 中,并且在此时进行 Pod 和所选节点的绑定。

注意:这时还会保存ALIYUN_COM_GPU_MEM_ASSIGNED的 Pod annotation,它被初始化为“false”。它表示该 Pod 在调度时刻被指定到了某块 GPU 卡,但是并没有真正在节点上创建该 Pod。ALIYUN_COM_GPU_MEM_ASSUME_TIME代表了指定时间。

如果此时发现分配节点上没有 GPU 资源符合条件,此时不进行绑定,直接不报错退出,默认调度器会在 assume 超时后重新调度。

  • 调用 Kubernetes API 执行节点和 Pod 的绑定

以下图为例,当 GPU Share Scheduler Extender 要把 gpu-mem:8138 的 Pod 和经过筛选出来的节点 N1 绑定,首先会比较不同 GPU 的可用资源,分别为 GPU0(12207),GPU1(8138),GPU2(4069),GPU3(16276),其中 GPU2 所剩资源不满足需求,被舍弃掉;而另外三个满足条件的 GPU 中, GPU1 恰恰是符合空闲资源满足条件但同时又是所剩资源最少的 GPU 卡,因此 GPU1 被选出。

GPU 虚拟化实践_Pod_08


3. 节点上运行

当 Pod 和节点绑定的事件被 Kubelet 接收到后,Kubelet 就会在节点上创建真正的 Pod 实体,在这个过程中, Kubelet 会调用 GPU Share Device Plugin 的Allocate方法, Allocate方法的参数是 Pod 申请的 gpu-mem。而在Allocate方法中,会根据 GPU Share Scheduler Extender 的调度决策运行对应的 Pod

  • 会列出该节点中所有状态为 Pending 并且ALIYUN_COM_GPU_MEM_ASSIGNEDfalse的 GPU Share Pod
  • 选择出其中 Pod Annotation 的ALIYUN_COM_GPU_MEM_POD的数量与 Allocate 申请数量一致的 Pod。如果有多个符合这种条件的 Pod,就会选择其中ALIYUN_COM_GPU_MEM_ASSUME_TIME最早的 Pod。
  • 将该 Pod 的 annotation ALIYUN_COM_GPU_MEM_ASSIGNED设置为true,并且将 Pod annotation 中的 GPU 信息转化为环境变量返回给 Kubelet 用以真正的创建 Pod。


GPU 虚拟化实践_CUDA_09

4.操作过程
#修改配置文件
 #Add volume mount into Pod Spec
- mountPath: /etc/kubernetes/scheduler-policy-config.jsonname: scheduler-policy-configreadOnly: true
- hostPath:
      path: /etc/kubernetes/scheduler-policy-config.jsontype: FileOrCreatename: scheduler-policy-config
 
#install the kubectl extension      
wget https://github.com/AliyunContainerService/gpushare-device-plugin/releases/download/v0.3.0/kubectl-inspect-gpushare
chmod u+x /usr/bin/kubectl-inspect-gpushare

kubectl label node hw-test-smartva-t4-ctr-server gpushare=true
node/ labeled

GPU 虚拟化实践_CUDA_10


GPU 虚拟化实践_API_11

通过共享GPU的集群调度能力,可以使多模型服务共享GPU资源,支持按显存、按卡分片GPU资源,显著提高了GPU的利用率。我们借鉴了阿里的vGPU共享调度方案,在不做侵入式修改的情况下,支持kubernetes集群上可插拔式GPU分片策略。

GPU 虚拟化实践_API_12

四、总结

目前T3出行算法平台已初步具备了GPU的共享调度能力,并在测试环境完成了多开发notebook与多模型服务的GPU共享,验证了方案的可行性。在多容器的显存和算力隔离的基础上,满足了针对GPU的资源分片,针对算法开发,可在有限显卡下满足更多用户的加速训练,对于模型服务,则大大提升了GPU的利用率。后续我们将在多场景下测试共享GPU在线服务的性能,为在线部署提供量化的指标参考。

五、参考文献

https://developer.baidu.com/article/detail.html?id=293237

https://www.cnblogs.com/lingr7/articles/16931589.html