转载自:https://zw0610.github.io/ ,如侵联删。

1 GPU 共享一:你真的需要吗?

从今天开始,我会连载三篇关于 GPU 共享的思考,分别从三个角度和大家共同讨论有关于 GPU 共享的问题。

这篇是三者中的开山之作,从需求的角度来探讨我们是否真的需要 GPU 共享。

1.1 Buzzword

GPU 共享似乎是近来相当热门的词汇。在为数不多的和客户交流的场合中,都听到了 GPU 共享的需求。

那什么是 GPU 共享呢?简而言之,就是把一块 GPU 让多个任务“同时”占用。

这里的“同时”加了引号,这是因为当我们直接将一块 GPU 指派给两个或多个任务时,GPU 并不是真正的同时处理来自这两个或多个任务发来的 GPU 指令的。(当然我们这里谈论的都是 Nvidia GPU。)每个任务需要拥有一个 GPU context 才能向 GPU 发出指令。而面对不同的 contexts, GPU 会采取时间切片的方式在不同的 contexts 之间做切换。而甲任务发出的 GPU 指令只有当 GPU 切换到甲 context 时才能被执行。不用担心,GPU 指令(这里指 GPU kernel function)是有 timeout 的,不会霸占着 GPU 不让切换 context。而需要注意的是,切换 context 是有成本的,而且成本并不廉价。

以上内容,以客户的水平基本是不会知道的。所以一些似懂非懂的客户在 GPU 共享之后可能会拿着 benchmark 找你逼逼,“为什么原来 10 分钟跑完的训练共享之后要跑 12 分钟了?!”。想必看过上面一段,你是可以回答这位客户的了。

那么客户是怎么想的呢?

A)老黄卖的 GPU 真贵,而且还不能买游戏卡做训练(仅限正经客户,不正经的客户有的是用 1080 Ti 的);

B)小刘写的代码真不行,每次跑起来只能用掉 2G 显存,剩下的 8 G显存全浪费了。

不得不承认,人家说的还是有道理的。可是客户很少会让小刘把代码优化优化,更不要提跟老黄去讨价还价了。客户最直接的办法,就是甩给你一个单子:“中央已经决定了,让你来做 GPU 共享”。

可惜不像有的人,嘴上说着“另请高明”。钱够就做。

1.2 鞋与脚

当灰姑娘的姐姐们硬要把自己的猪蹄塞进水晶鞋的时候,作为旁观者一定是忍俊的。

各位客户的猪蹄大致包含了以下几种类型:

  1. 神经网络的训练
  2. 神经网络的推理
  3. 其他 GPU 应用

1.2.1 GPU 资源

通常一块 GPU 的资源可以从两个相对独立的维度来描述:GPU 显存和 GPU 算力。一个任务,其对 GPU 显存的占用和其对 GPU 算力的使用并不一定呈现线性关系。显然是存在占用 GPU 显存很多但算力很少的应用,亦或是只占用少量显存但频繁计算的应用。

然而在深度学习领域,我们一般可以认为真实显存的占用和 GPU 算力的使用是正相关的。为什么要说“真实显存占用”呢?因为存在 TensorFlow 这样比较“自以为是”的应用,觉得自己管理显存更在行,于是在程序运行之初便申请全部显存后自己管理。显然其占用的(全部)显存并不全部被用作训练或推理。好在我们可以改变这种行为,在这里不再赘述。

1.2.2 推理服务

有了对 GPU 资源的基本认识,我们先来看看第二种:神经网络的推理。

推理服务相对训练,其对 GPU 显存的占用是明显下降的。大量激活函数由于其非光滑的特性,必须保留中间变量以便日后求导。而这些变量在推理服务中即可舍去。

与此同时,没有了反向传播的需求,对于计算量的需求大幅下降。加之一般推理请求的特性就是 request-after-request,往往不像训练时那样组成 batch,导致单次推理操作对 GPU 算力的使用也很低。

这样一来,我们可以总结,推理服务属于那种显存和算力使用都较小的 GPU 应用。可以想象,如果把一块 GPU 单独指定给一个推理服务,那必然会造成资源的极大浪费。这样看来,GPU 共享这双水晶鞋确实找到了灰姑娘的那双脚。

1.2.3 模型训练

模型训练与推理相比,不仅因为反向传播增添了对 GPU 显存和算力的需求,更关键的是调参员可以通过 Batch Size 来调整对 GPU 显存以及算力的使用。

下次如果灰姑娘的姐姐拿着 Batch Size 为 8 的分布式训练 case 找来的时候,我希望她可以明白:“命里有时终须有,命里无时莫强求”。

有人会问,那如果是一块 GPU 分给几个用户同时 debug 模型呢?首先,如果是为了跑通代码,没有必要用 GPU。如果要验证模型的初始效果,用半块 GPU 在那里测试的体验远不如提交一个任务查看任务的收敛。所以对于这种需求,与其期待 GPU 共享为用户带来更好的体验,不如考虑直接从训练脚本提交任务并直接查看训练结果来得更好。

1.2.4 其他 GPU 应用

那么其他的 GPU 应用,如果依然遵循显存与算力正相关的规律的话,可以依照“小显存,小算力”和“大显存,大算力”来划分。

举个栗子,Nvidia MPS 提供的案例即为 N-Body 模拟。至于这个应用是否属于“小显存,小算力”,我觉得还是交给大家自己去验证来得更好。

1.3 假的灰姑娘?

读到这里,你是否也觉得,嗯,模型推理服务确实是适合 GPU 共享的应用呢?

“真的吗?我不信。” —— 鲈鱼

我们是否真的要把多个模型服务的应用调度到同个 GPU 上呢?

在刚才的分析中,推理服务 request-after-request 的特性,即无法 batching 的特性是导致“小显存,小算力”的原因。那我们是否可以改变这一特点呢?当然不是改变用户习惯,而是采取暂存的方式,将一段时间内的 requests 打包成一个 batch 送入 GPU。

退一步讲,假设 batching 效果不佳,我们是否要就真的要在一块 GPU 上起多个模型服务,并且忍受 context 切换带来的 overhead 呢?

感兴趣的朋友可以去翻看翻看 Nvidia Triton Inference Server 如何实现同一个 context 下利用多 stream 的方式实现多个模型同时实现前向推理功能的。结合更灵活且智能的 model scheduling,相信可以实现更好的效果。

2 GPU 共享二:调度

GPU 共享的第二篇,我们会看看开源的集群调度器一般是怎么处理的 GPU 共享调度的。由于我对调度器的了解有限,在这里我们只对 Slurm 调度器以及 Kubernetes (阿里云的开源方案)进行简单的介绍。

2.1 Slurm & GRES

Slurm 非常有“前瞻性”的提出了 "Generic Resources"(GRES)的概念,把一些非常见的资源归到了这伊磊。

默认的调度器无法处理 GRES,一般来说需要安装对应的 GRES Plugin。当然,如果在部署 Slurm 之前就已经在集群上装好了 NVML(一般随着驱动就会安装),Slurm 会利用 select/cons_tres plugin 自动进行检测并将(Nvidia)GPU 资源登记。 而在调度 GPU 任务时,Slurm 依旧使用 CUDA_VISIBLE_DEVICES 来对多 GPU 的节点进行以卡为单位的 GPU 资源隔离。每个任务在节点上被拉起时,Slurm 都会在 Epilog 和 Prolog 来配置 CUDA_VISIBLE_DEVICES 使得任务可以使用的 GPU 受到限制。这些配置都是在 Slurm 的 master 节点上完成的。

讲了这么多,依然说的是 GPU 的调度,而不是共享 GPU 的调度。接下来,我们来看看 Slurm 如何处理 GPU 共享调度。

除了 GPU,Slurm 的 GRES 还支持一种跟 Nvidia 相关的资源:CUDA Multi-Process Service (MPS)[https://docs.nvidia.com/deploy/pdf/CUDA_Multi_Process_Service_Overview.pdf]。如果对 MPS 尚不了解,我建议先简单看看 MPS 的 example。本质上,MPS 就是允许多个小任务同时运行在一块 GPU 上,并且前一篇文章中提到的 overhead 也大大降低。MPS 通过设置 CUDA_MPS_ACTIVE_THREAD_PERCENTAGE 来限制每个任务的算力。CUDA_MPS_ACTIVE_THREAD_PERCENTAGE 的取值在 (0,100] 之间,也就是说,MPS 将一块 GPU 的算力等分成了 100 份。(所以在 Slurm 中,虽然可以配置一块 GPU 的 mps 为 100 的 N 倍,但是依然会被折算成百分比。)

需要注意的是,一块 GPU 不能同时被设定为 gpumps 资源。这对调度器是一种不现实的要求。可以通过修改每个节点上的 gres.conf 文件来对该节点上的资源进行配置。

用户在给任务配置资源的时候,直接通过配置 --gresmps:50 来进行 GPU 共享资源的分配。Slurm 调度器在处理 mps 资源时,也会在选定节点和 GPU 之后配置 CUDA_VISIBLE_DEVICES 来限定 GPU 资源的使用。

可见,Slurm 对 GPU 共享的调度已经做到相当原生。而如果有进一步的需求,也可以通过 Slurm Plugin API 自己来实现一个。

2.2 Kubernetes & AliyunContainerService

Kubernetes 本身对 GPU 是支持非共享调度的。主要依靠的就是 Device Plugin。如果对 k8s 如何利用 device plugin 进行 GPU 调度流程尚不熟悉的,可以先去看看其他的文章。这里就不赘述了。

可以知道,想要突破 k8s 上对 GPU 的调度限制,有两点必须要做:

  1. Node 上必须可以将一块 GPU 多次绑定到不同的容器
  2. Scheduler 必须处理非 nvidia.com/gpu 这一类的资源

与 Slurm + MPS 按照算力分割略有不同的是,阿里的方案以显存为分割尺度,并且默认地认为 GPU 算力的需求和显存的需求是成正比的。这也是有一定合理性的。

那么先看看阿里是怎么处理第一类问题的。拉起 container 过程由 kubelet 完成,节点上的 device-plugin 只提供节点上加速器(即 GPU)的状态。这个时候可以选择修改 kubelet。然而修改 kubelet,会使得方案很难在其他的 k8s 集群上部署。所以阿里提供了新的 device-plugin。它将用以共享的 GPU 当作 Extended Resource 注册,并且统计节点上所有 GPU 显存的总和。当 kubelet 调用 device-plugin 的 allocate API 时,device-plugin 会先通过 k8s API 获取所有被调度到该节点但尚未被处理的 GPU Sharing Pod,而后选择老的 Pod(等待时间最久),为其在环境变量中配置从 annotation 获取的 Device ID 给 CUDA_VISIBLE_DEVICES 以便实现 GPU 与 GPU 之间的隔离,最后标记为 assigned

而在调度器一层,阿里的方案使用的工具是 Extended Scheduler。当前用以共享的 GPU 已经被注册为一种新的资源 Extended Resource,而一旦 Pod 被调度到。现在需要做的就是让 k8s 的调度器可以正确处理相关的 Pod。由于默认调度器仅能判断一个节点上的 GPU 显存是否足够容纳当前的 Pod,因此 GPU Share Scheduler Extender 会帮助其在做一次过滤,将那些单个 GPU 不足以容纳下显存申请的节点过滤掉。在选择好节点之后,binding 的过程也交由 Scheduler Extender 完成。当中主要的工作是在选择好的节点中,以避免资源浪费的形式选择合适的 GPU、将选择好的 GPU ID 写入 Pod 的 annotation、将 Pod 与 Node 绑定。

3 GPU 共享三:井水不犯河水

在最后,我们来讨论多个任务共享 GPU 时,算力和显存是否可以做到井水不犯河水。当前开源的方案主要是腾讯 TKEStack 的 GaiaGPU。VirtAI 也有一个类似的功能的社区版,我们也会稍作讨论。

3.1 API 劫持

我们先来看看 CUDA 任务使用显存的基本逻辑吧。我们以申请一块 NxN 的矩阵为例。

  1. 可以预先计算一下 NxN 的 float32 矩阵需要多少空间:uint32 bytes = N * N * sizeof(float);
  2. 查询整块 GPU 的显存总量或当前剩余的 GPU 显存总量
  3. (如果剩下的显存量够多)利用 CUDA Runtime API 或者 CUDA Driver API 向 GPU 申请 bytes 那么多的显存
  4. 确认申请显存的操作成功

那么当多个任务同时在一块 GPU 上进行显存申请的时候,我们希望做到和上述步骤不同的地方是:2. 查询显存总量/剩余显存总量时,返回的不希望是整块 GPU 的显存相关信息,而是限制之后的相关信息。举个例子,一块 GPU 一共有 12Gi 显存,我们分配给任务 A 5Gi 显存,给任务 B 3Gi 显存。当任务 A 中的进程调用 cuDeviceTotalMem 时,应该返回 5Gi。假设任务 A 中的进程已经使用了 1.5 Gi 的显存,那么当调用 cuMemGetInfo 时,应该返回 3.5 Gi。

而如果有的用户程序不经过步骤 2,直接执行步骤 3 呢?显然,我们需要根据上述逻辑对 “如果剩下的显存量够多” 作出判断。

那么要修改 CUDA 函数的实现,一般可以走 LD_PRELOADLD_LIBRARY_PATH 两种方式。前者的模式适用范围有限,无法进一步劫持由 Runtime API 对 Driver API 的调用。因此 TKEStack 采取修改 libcuda 并通过 LD_LIBRARY_PATH 加载。具体的代码可以参看:vcuda-controller

3.2 负反馈调节?

在解决了显存问题之后,我们来看看算力是否也能通过劫持做到限制?

在腾讯的 GaiaGPU 这篇文章中,号称可以做到限制。其中心思想,在我看来是一种朴素的控制理论:负反馈调节。

每次发起 CUDA kernel 的时候,都需要检查一下当前任务对 GPU 的使用率,并对本次 CUDA kernel 会增加的 GPU 使用率作出估计。如果预计本次 CUDA kernel 会使得 GPU 使用率超标,则延缓 kernel 的运行,直到当前的 GPU 使用率下降至允许本次 CUDA kernel 的运行之后。然后这样做,无法避免多个任务的 context 切换带来的 overhead。

劫持的工程实现是做在 vcuda-controller 上的。

3.3 逆向工程

这里简单讲一下 VirtAI 的社区版跟 TKEStack 的异同之处。

VirtAI 社区版其实只是供社区使用的一个安装包,里面不含任何代码。我是通过抓去安装包内的信息大致作出的推测。

  1. 两者都采取了劫持 CUDA API 的方式,但 VirtAI 不仅劫持了 Driver API,还同时劫持了 Runtime API
  2. VirtAI 的将所有的 API 用 rpc 进行了包装,使之可以运行在调用 API 的进程之外,这样也就解释了为什么 GPU 节点上会有 orind 这个 daemon 进程存在
  3. VirtAI 号称实现了类似 MPS 的多 CUDA 进程无 context 切换,这是怎么操作的尚还不知晓

4 GPU 共享四:MIG 带来的补充

原本写完三篇关于 GPU 共享思考之后是可以收声了,奈何昨天核弹黄又在 GPU 共享圈里扔了一颗:MIG。

鉴于 CUDA 11 尚未发布,A100 的 White Paper 也暂无音讯,所以先就着已经发布的 Nvidia 技术博客与大家一起来看看 Nvidia 刚刚发布的支持容器层面共享的 GPU 共享技术。

4.1 从硬件出发的彻底隔离

之前 Nvidia 的两种 GPU 共享的方案各有各的问题:

  1. GRID 方案可以做到完全资源(算力和显存)隔离,但是必须依托虚拟机,无法在容器上直接挂在分割后的 sub-GPU。
  2. MPS 方案可以对接容器(Volta 之后的卡),对算力也能做限制,且无需担心 context switch 带来的 overhead,因为 MPS daemon 将各位 clients 发来的 context 通过 daemon 自身的 context 透传到 GPU。但是受限于硬件,对于显存、IO 带宽,MPS 方案无法做到限制,往往需要额外的组件来处理显存相关的操作。除此之外,之前提到的 MPS context 的错误无法被隔离。这样一来,一个 client 发生了错误,或者说 daemon context 发生了错误,会影响到其他的 client CUDA 程序的运行。

那么 Ampere 带来的 A100 所具备的 Multi-Instance 呢?Reference

MIG 从硬件的层面不仅对 SM(Stream-Multiprocessor,流处理器)进行了分割,还对整个内存系统进行了分割,包括了 Bus、DRAM、L2、Memory Controller 等。这样一来,不同 GPU instance 上的用户程序可以享受不受打扰的显存、带宽等资源。

GPU共享-StubbornHuang Blog

与此同时,blog 中也提到,新的 QoS 使得单个 instance 上的错误并不会影响到其他 instance 上的 CUDA 程序。这对生产实践助益颇多。

当前 MIG 的分割方案还是比较固定的,一张卡最多可以分成 7 份。当然除了分成 7 份,还有其他力度的分割方案:

GPU共享-StubbornHuang Blog

暂时还没想到为什么要像魂器一样分成 7 份,而不是 2^3 份。

鉴于 A100 的庞大算力,即便分成 7 份做一般模型(不是很庞大的模型)的推理服务其实并不划算,可能更多的使用场景还是多用户同时调试模型或直接做小模型的训练。

总体来看,利用 Ampere 这代架构在硬件上的隔离,无论是公有容器云还是私有容器云都可以很快地部署带 GPU 共享的 Kubernetes 集群,并且做到完整的算力、显存隔离而不需要额外的一些组件。

参考链接