怎样节省 2/3 的 GPU?爱奇艺 vGPU 的探索与实践
随着人工智能技术的发展,爱奇艺内部越来越多的服务使用深度学习模型和技术来驱动,为我们的用户提供更加智能和便捷的在线视频观看体验。其中在线类的服务,通常单个容器实例需要独占一个 GPU,以实现在毫秒/秒级延时内完成例如视频、图片、语音、文本的深度学习模型推理请求;为了保证响应延时,请求通常单独进行,无法对请求做 batch 以提升计算效率,且不同请求间隔随机,会导致这些服务的 GPU 计算资源的利用率通常较低(如图 1 所示)。且在线类服务请求量在一天或者一定时间周期内存在波峰波谷的现象,进一步降低了 GPU 的利用率。鉴于 GPU 本身高昂的价格,较低的 GPU 利用率浪费了大量计算资源,增加了 AI 服务的成本。
图 1:在线推理服务 GPU 利用率统计
在此背景下,最直接的解决方案是将多个服务部署在同一张 GPU 卡上,在保证服务质量的前提下通过 GPU 共享来提升 GPU 的利用率。目前英伟达官方的 GPU 共享技术主要有两种方案:
(1)vGPU ;(2)MPS。
接下来我们将简单对比下两种方案。
Nvidia vGPU 方案
Nvidia 的 vGPU 方案采用虚拟化的技术,基于 SR-IOV 进行 GPU 设备虚拟化管理,在驱动层提供了时间分片执行的逻辑,并做了一定的显存隔离,这样在对显卡进行初始化设置的时候就可以根据需求将显卡进行划分。其中时间分片调度的逻辑可以是按实例均分,或者是自定义比例,显卡的显存需要按照预设的比例进行划分。Nvdia 的 vGPU 方案在实施中有下面两点限制:
(1)vGPU 划分完成之后,如果要改变这种预定义的划分,需要重启显卡才能生效,无法做到不重启更改配置。
(2)其方案基于虚机,需要先对 GPU 物理机进行虚拟化之后,再在虚拟机内部署容器,无法直接基于物理机进行容器化的调度,另外 vGPU 方案需要收取 license 费用,增加了使用成本。
Nvidia MPS 方案
Nvidia 的 MPS 方案是一种算力分割的软件虚拟化方案。该方案和 vGPU 方案相比,配置很灵活,并且和 docker 适配良好。MPS 基于 C/S 架构,配置成 MPS 模式的 GPU 上运行的所有进程,会动态的将其启动的内核发送给 MPS server,MPS Server 借助 CUDA stream,实现多个内核同时启动执行。除此之外,MPS 还可配置各个进程对 GPU 的使用占比。
该方案的一个问题在于,各个服务进程依赖 MPS,一旦 MPS 进程出现问题,所有在该 GPU 上的进程直接受影响,需要使用 Nvidia-smi 重置 GPU 的方式才能恢复。
01 爱奇艺的 vGPU 方案
调研以上方案后,为了更好地适用于爱奇艺内部 AI 容器化应用场景,我们重新开发了容器场景下的 GPU 虚拟共享方案,基于 CUDA API 截获方式实现显存及算力隔离和分配,并基于开源项目 aliyun-gpushare scheduler[1]实现 K8S 上对虚拟 GPU 的调度和分配,实现了多应用容器部署在一张 GPU 卡的目标。
我们方案的主要特点是配置灵活,和 K8S 能够有机的进行结合,按需实时分配用户所需要的 vGPU 实例,同时尽可能的让物理 GPU 实例能够充分的被共享,实现资源的最大化利用。
在完成方案的设计之后,我们对整体进行了效果的评估,测试这种隔离和共享对应用性能的影响。即对于单一进程来说,需要保证:首先它不会使用超过其被分配的算力大小,其次隔离本身不应该对于 GPU 算力有过多损耗,第三是多个进程同时共享的时候,与其单独运行时相比,不应有太大的性能偏差,即共享可以有效避免进程之间的干扰。
针对以上标准,我们对 GPU 虚拟共享方案进行了性能测试,结果如图 2 所示。
第一个测试是单进程算力隔离后性能的评估。物理 GPU 上只运行单一进程,但配置了三次,分别为 100%,50%和 10% 算力时,其性能和该程序独立运行时的比例关系。纵轴为达到无虚拟化运行时性能的百分比,横轴为进行的单元测试用例,区域相同的颜色表示该组测试用例为同一 CUDA kernel,但是不同的运行参数。其中图内的绿点,蓝点,和红点分配是 500 多个测试用例在各自算力分配的情况下得到的性能,和完全没有算力分割且独占 GPU 时运行的性能的相对比值。另外曲线是这些独立点的数值在全体维度上做了一个平滑,以更好的进行可视化的对比。
第二个和第三个测试分别用不同算力配比对两个 GPU 进程进行相互干扰实验。如第二个两个进程分别配置为 50% 算力,绿点为两个 GPU 进程性能平均值,而红色曲线为这些绿点的平滑曲线。该曲线和第一个测试中 50%算力的曲线对比相差无几,这就说明了我们方案中配置 50%算力时同时运行相互干扰是几乎可以忽略的。第三个为一个配置为 70%,另外一个配置为 30%算力,同样可以和第一个测试中的独立分配 70%/30%时各自的曲线进行对比。
测试结果表明了方案可以将 GPU 相互干扰控制在合理的范围之内。服务上线后内部统计显示,平均 100+ 深度学习容器服务可以共享的部署在 35 张物理 GPU 之上,并且做到应用相互之间无影响;对于单张 GPU 物理卡,平均承载的服务数量从 1 变为了 3;同时 GPU 的平均利用率也提升了 2 倍以上。
图 2:隔离性测试结果
02 爱奇艺 GPU 虚拟共享的底层原理
首先我们来看看 GPU 虚拟共享的底层原理。GPU 作为一个强大的计算外设,提供的两个主要资源是显存和算力。要实现单个 GPU 的共享,我们要实现对显存和算力资源的隔离,并验证隔离方案的效率及性能。
2.1 显存隔离
对于深度学习应用来说,对于显存的需求来自于三个方面。
1)第一是模型的 CUDA kernel context,可类比于 CPU 程序中的 text 段,提供给 CUDA kernel 执行的环境,这是一项刚需,没有充足的显存,kernel 将无法启动,且 context 的大小随着 kernel 的复杂程度有增长,但在整体模型显存需求中是最小的一部分。
2)第二部分来自于模型训练得出的一些参数,如卷积中的 weight 和 bias。
3)第三部分来自于模型在推理过程中的临时存储,用于储存中间的计算结果。
对于一般的模型来说,基本都不需要占用整个 GPU 的显存。但是这里有一个例外,Tensorflow 框架默认分配所有 GPU 的显存来进行自己的显存管理。当然 Tensorflow 框架有相应的选项可以屏蔽该行为,但是对于平台来说,要让每个用户修改 TF 的配置为屏蔽该行为,就不太可行。
为应对这一问题,一个巧妙的方法可以在不需要应用开发者参与的情况下,让 Tensorflow 的部署应用只分配它所需的显存大小而不出现问题。该方法即 API 动态拦截。Tensorflow 之所以可以知道当前 GPU 的剩余显存,是通过 cuDeviceTotalMem/cuMemGetInfo 这两个 CUDA library API。通过 LD_PRELOAD 的方式,在的钩子 so 中实现这两个 API,那么 Tensorflow 执行的时候,link 首先会调用的是的 API 实现,而不是 CUDA 的,这样就可以动态的修改这两个 API 的返回结果,如这里想做的,将特定 Tensorflow 应用的显存配额限制在其申请数值。
在系统实现的过程中,还对 cuMemAlloc/cuMemFree 做了同样的拦截,目的是为了能够对同容器中的多个 GPU 进程进程统一管理。当多个 GPU 进程分配显存之和超过其配额时,可以通过 cuMalloc 来返回显存不足的错误。容器内显存配额管理是通过 share mem 来做的。图 3 展示了显存隔离和分配的整个流程。
图 3:显存分割中隔离和分配流程
2.2 算力隔离
除了显存,另外一个重要的 GPU 资源是算力。对于 Nvidia volta 显卡架构来说,它的算力来源于三个方面,浮点计算单元、整形计算单元、tensor core 加速计算单元。其中浮点计算单元和整形计算单元是流处理器 SP 的内部结构,而 SM 中包含着多个流处理器 SP。对于 V100 来说,它有 80 个 SM,每个 SM 中有 64 个 SP,合 5120 个流处理器,其中 tensor core 是位于 SM 的内部,与 SP 共享寄存器/share mem/L1 cache。图 4 给出了 Nvidia GPU 的硬件架构组织关系框图。
图 4:Nvidia GPU 硬件架构组织关系图
对于 Nvidia 的 GPU 的编程语言 CUDA 来说,它的语言设计逻辑上和上图的硬件层次是对应的。CUDA 有三层逻辑层次,分别为 grid,block,和 thread。Grid 可以认为是对整个显卡的逻辑抽象,block 可以认为是对 SM 单元的逻辑抽象,而 thread 是 SP 的逻辑抽象。为了达到最高的并发程度,SM 之间可以认为是没有交互的,当然这也不是绝对的,有一些程序为了自己的特殊逻辑,也可以设计出 SM 之间依赖的程序,但这个代价是极大的性能浪费。
在知道了 GPU 的底层结构,以及 CUDA 的设计原理之后,可以就如何算力虚拟化来做一下初步设想。既然一些模型无法完全利用 GPU 的全部算力,那么何不削减其占用的 SM 个数,使得空闲下来的 SM 可以为其他 GPU 程序所用?
这样的想法是好的,但是一些限制阻止了这种优化的实现。GPU 程序的执行,是通过 kernel 的片段来具体实施,在 CPU 侧 launch 了 kernel 之后,具体的 kernel 及其调用参数随即交由 GPU 的硬件调度器来在某个未来的时间点真正运行起来。在默认的情况下,kernel 是被派发给 GPU 上所有的 SM,且执行过程中不能被中断。如图 5 所示,软件系统在发送完毕启动命令之后,随即命令及参数由 PCIe 转交给 GPU 硬件,并插入其队列中,由 GPU 硬件中固化的逻辑去具体处理在何时真正启动。
图 5:GPU 软件和硬件调度系统的交互图
但世事无绝对,默认情况下不行,不代表没有别的办法。让我们再来回顾一下 CUDA 的设计。CUDA 作为一个用于操控 GPU 来完成高效并行计算的语言,它的代码编写逻辑是以 thread 为基本单元的。SM 上所有 SP 都运行着一份 kernel 的代码,且在一定程度上来说连运行节奏都完全一致。CUDA 中用来区分 thread,来判断代码应该处理数据的偏移量的方法,是通过 CUDA 中的 blockIdx/threadIdx 这两个内嵌变量。这两个变量在机器码上是只读的,在 thread 由硬件调度器派发的时候所指定。通过硬件调度器,就完成了抽象的 blockIdx/threadIdx 和具体的 SM/SP 的绑定。图 6 大概的描述了这一映射关系。
图 6:CUDA 逻辑块和硬件计算单元的映射关系
为了能够精确的控制算力,我们就不能再依赖硬件调度器来控制内核启动。在这里用了一个取巧的方法,就是让内核启动之后被“困”在固定数目的 SM 上面,这个数目的值和 GPU 整体 SM 个数的比例就是给这个内核算力配比。
为了形象化来阐述思路,这里我们对 GPU 做了一个抽象化的改动,SM 的个数被定义为 10 个。然后有一个启动参数为<<<15,1>>>的内核,即 CUDA block size 为 15,thread size 为 1。它正常启动的时候,硬件调度器会给每一个 SM 上分配一个内核的副本。这样在第一时间就消耗了 10 个 block 的副本,随后每个 SM 上内核执行完毕之后会退出,硬件调度器会进一步分配剩下的 5 个 block 副本,在这个也执行完毕之后就完成了整个内核的执行。
算力切分之后,我们会在内核启动时,动态的修改其启动参数,将其 CUDA block size 从 15 变为 5。这样硬件调度器就会将内核副本分配到 GPU 上一半数目的 SM 上,空闲的一半可以为其他内核所使用,如图 7 所示。
图 7:动态修改启动参数来进行算力分割
我们虽然通过动态修改启动参数的方法,避免了内核占满全部 SM 资源,但此时还没完成“困”这一动作。所以此时的内核行为是其完成预定逻辑之后,会退出,导致此时内核不能覆盖 block size 为 15 时的数据空间。为了将其“困“住,我们在内核的汇编 EXIT 处,替换成了 BRANCH 操作。这样内核完成本身的逻辑之后,会跳转到我们预设的一段逻辑中。这个逻辑完成虚拟 blockIdx/threadIdx 的自增操作,随后再跳转到内核开始位置,来基于更新的 blockIdx/threadIdx 来进行新一轮计算。
这次需要指出的是 blockIdx/threadIdx 为只读寄存器,所以没办法直接更改它的值。作为一个替代的解决方案时,将内核中的 blockIdx/threadIdx 进行整体替换为可写的寄存器,这样我们就可以在预设的跳转逻辑中做更改操作,如图 8 所示。
图 8:汇编修改更改内核运行逻辑
03 爱奇艺 GPU 虚拟共享的调度设计
完成了 GPU 底层的资源隔离之后,我们还需要基于 K8S 平台实现对隔离的 GPU 资源的分配和调度管理,以方便业务的深度学习服务能够快速部署到共享的 GPU。
K8S 容器中使用 GPU 的方案一般采用 Nvidia device plugin(英伟达官方插件),它可以为 Pod 分配一卡或多卡,分配的最小单元是 1 张卡,无法支持底层隔离的 GPU 资源调度。调研之后,我们选择阿里云容器服务开源的 aliyun-gpushare 作为调度方案,实现对 GPU 隔离资源的调度。
以显存为例,使用 aliyun-gpushare,为 Pod 分配的是一张卡中的部分显存,这样从逻辑上说,单卡的资源就可以再进一步被切分。假设有一张 V100 32GB 卡,可以给 Pod1 分配 4GB 显存,也可以同时给 Pod2 分配 8GB 卡,直到 32GB 显存分配完毕。整个调度过程如图 9 所示
图 9:阿里公开的整体调用方案图
其中,Share GPU Device Plugin 和 Share GPU Schd Extender 是主要的新增组件,下文简写为 SGDP 和 SGSE。其余的组件均为 k8s 官方组件。
图中的主要流程如下:
用户创建一个 Share GPU Pod 时,必须带 aliyun.com/gpu-mem 这种 K8S 自定义资源,表明其需要多少显存。
SGSE 根据用户的 Share GPU 显存请求和集群整体资源情况,给该 Pod 分配一个 Node,并通过 patch Pod annotation 来指定使用某张卡。
Kubelet 调用 SGDP 的 Allocate 方法,将指定的 GPU 卡分配给 Pod 使用,同时设置环境变量 ALIYUN_COM_GPU_MEM_CONTAINER(容器可用显存)、LD_PRELOAD(其值为限制显存的动态链接库路径)。
Pod 启动后,因为设置了 LD_PRELOAD,所有 AI 框架的 GPU 显存申请都被动态链接库的钩子劫持,当使用的总资源超过 ALIYUN_COM_GPU_MEM_CONTAINER 的值,则拒绝请求。从而达到限制用户使用显存的效果。
算力资源的调度策略类似以上显存调度。
实践中,对于 1 张物理 GPU 卡,我们进行 1/4 和 1/2 的显存和算力的分割,业务可根据实际需要选择对应的比例,单张 GPU 最多可部署 4 个不同应用,并可实现有效隔离,互不影响。
04 结语和展望
通过底层 LD_PRELOAD 动态劫持的方式,我们实现了容器中轻量级 GPU 显存和算力的隔离,从而支持多个容器应用部署在同一个 GPU 上。该方案从一个动态的维度实现单张 GPU 资源的划分,针对在线推理服务场景,很好的提升了 GPU 硬件的使用效率。
后续工作中,我们还计划开发和实现跨主机 GPU 远程调用的方案,来解决 GPU 虚拟共享之后,产生的单机多卡机器上 CPU/GPU 比例失衡,导致的部分虚拟 GPU 资源无 CPU 可分配的问题。
参考文献:
1.aliyun-gpushare: https://github.com/AliyunContainerService/GPUshare-scheduler-extender
2.Nvidia vGPU:https://docs.Nvidia.com/grid/latest/grid-vGPU-user-guide/index.html
3. Nvidia MPS:https://docs.Nvidia.com/deploy/mps/index.html
评论