icon-cookie
The website uses cookies to optimize your user experience. Using this website grants us the permission to collect certain information essential to the provision of our services to you, but you may change the cookie settings within your browser any time you wish. Learn more
I agree
blank_error__heading
blank_error__body
Text direction?

如何在Kubernetes实现GPU调度及共享

RancherLabs 2019-06-03
 

R

6月20日,北京,由Rancher Labs主办的【2019企业容器创新大会】限免报名已开启!全天18场演讲,特邀中国人寿、中国联通、平安科技、新东方、阿里云、百度云等著名企业的IT负责人,分享容器技术的企业级落地经验,年度容器技术盛宴不容错过。点击【阅读原文】了解详情及在线报名。

概  论

近年来AI技术的繁荣和深化,尤其是深度学习的崛起,离不开海量数据和计算力的提升。尤其是对Nvidia的GPU的利用,让深度学习获得几十倍的性能提升,才彻底打开了AI想象空间。虽然智慧芯片近年来有着百花齐放的风景,最典型的例如Google的TPU,但是平心而论,从普惠意义和生态上,Nvidia的GPU仍然占有主导位置。

不过,Nvidia的GPU无疑是昂贵的,所以如何最大化利用好GPU的硬件资源,是每一个算力平台型产品都要考虑的问题。比如,有多个用户使用GPU服务器进行训练时,如何保证资源合理的分配非常重要。得益于Nvidia公司为Docker写的Runtime,也就是Nvidia-Docker,使得在Docker里使用GPU成为可能。从容器粒度来管理和使用GPU要比从主机角度容易很多,因为运行GPU的AI任务通常配置非常复杂,这种复杂包括管理员从管理GPU卡的分配和使用者切换不同的训练环境,而容器可以封装不同的训练环境,很大程度上降低复杂性。此外,借助Kubernetes来管理Nvidia-Docker,使得GPU任务的分配更加简单和合理,目前已成为几乎所有主流的AI算力平台的方案。

Kubernetes支持通过Device-Plugin的方式来增加对默认资源(CPU,Memory等)之外的设备支持,而第三方可以通过编写相应的Device-Plugin来增加对设备的支持。目前Nvidia也是通过这样的方式对GPU进行支持。

K8s + Nvidia-Device-Plugin的方式两个限制:每一块GPU同时最多只能被一个容器使用;没有考虑GPU卡之间的通道亲和性。 这样的方式已经满足了部分AI算力需求场景,但是某些场景下还是有缺点和限制的: 每一块GPU同时最多只能被一个容器使用,这在训练模式下没有任何问题,但是在开发调试模式下会造成巨大的资源浪费。因为开发调试模式下用户大部分时间并没有实质运行GPU的资源,但却排他的独占了昂贵的GPU。此外在多卡主机架构下,GPU卡直接的连接通常是不一样的,有的通过Nvlink相连,有的通过PCIe,而不同的连接方式性能差别非常大。而没有考虑同个主机里GPU卡直接的通道亲和性时,也会给多卡计算时需要发生数据传输时(如all_reduce操作)带来过高的通信开销。那么自然而然,在同一个容器挂载多张GPU卡时,我们当然更希望会挂载通道亲和性更好的卡。

本文会介绍K8s进行GPU调度的通用流程和我们的一些改造方案。包括用于支持容器GPU挂载的Nvidia-Docker、K8s中将GPU作为拓展资源调度的Device-Plugin机制,以及针对原生Nvidia-Device-Plugin存在的问题的改造方案。

Nvidia-Docker的简单介绍

Nvidia-Docker是Nvidia官方对容器做的拓展,用以让容器能够支持Nvidia的GPU设备。据官方统计的数据标明,目前Nvidia-Docker的下载量已经超过200万次,可以得知目前使用Nvidia-Docker来做AI系统环境已经是非常主流的做法。

这里不详细介绍Nvidia-Docker的原理了,详细的原理可以阅读其官方的设计文档,这里只作简单的介绍。从2015年开始,Docker容器的诞生了一套容器运行时标准OCI(Open Containers Initiative),它包含容器运行时标准(Runtime-Spec)和 容器镜像标准(Image-Spec)。而著名的Runc则是这套标准的一个默认实现,然而任何满足该标准的实现都可以注册为容器的运行时拓展。Containerd则包装了Runc和其它功能如生命周期管理等,以Daemon的形式运行在主机。Nvidia-Docker正是基于这一套拓展标准增加Nvidia GPU的容器支持。Nvidia-Docker主要原理是将对GPU的支持放入一个兼容OCI标准的运行时库拓展libnvidia-container中,并在Runtime的API中进行调用,在libnvidia-container中通过共享和调用主机侧的nvidia-driver实现对GPU的支持。在容器启动时,Runc会调用一个叫做nvidia-container-runtime-hook的hook,这个hook会去检查相应的环境是否具有GPU的支持和一些环境检查,完成之后容器启动,在运行时容器内进程也是通过libnvidia-container暴露的接口进行交互,从而实现容器对GPU的透传和运行时支持。

(图片来源:https://devblogs.nvidia.com/gpu-containers-runtime)

值得注意的是,Nvidia-Docker容器会使用主机侧的Nvidia-Driver,再上层的软件栈如cuda/cudnn,AI框架等,则在容器里面提供。此外,多个Nvidia-Docker可以挂载同一个GPU,只要通过环境变量指定就好,并没有数量上的限制。

为了方便理解后面Device-Plugin的机制,这里简单介绍一下Nvidia-Docker挂载不同GPU设备的方式。Nvidia-Docker的使用非常简单,它通过指定一个环境变量来指定将要挂载的GPU设备,不过要在Docker的配置文件中指定Docker的Runtime为Nvidia-Docker,或者通过命令行显式指定也可以:

nvidia-docker run -e NVIDIA_VISIBLE_DEVICES=0,1 --runtime=nvidia -it tensorflow/tensorflow-gpu:v1.13 bash

如果在Docker配置中已经做过相关配置,那么就可以简化为:

docker run -e NVIDIA_VISIBLE_DEVICES=0,1 -it tensorflow/tensorflow-gpu:v1.13 bash

这里NVIDIA_VISIBLE_DEVICES这个环境变量用来指定需要绑定的GPU卡的逻辑ID,就可以实现容器中对该卡的绑定,使用上非常简单。

K8s的Device-Plugin机制

K8s通过Device-Plugin的机制对非默认的资源设备进行支持,例如RDMA设备、AMD GPU等,当然也包括本文最关心的Nvidia GPU。通过编写相应的Device-Plugin,第三方资源设备上可以在K8s中添加对相应设备的支持,让用户获得和原生资源(CPU,Memory等)近乎一样的使用体验。

Device-Plugin机制本质上是一个RPC服务。K8s定义了一个RPC调用接口,第三方资源设备方可以通过实现该接口来让该设备得以在K8s侧得以支持,并且在使用方式上和默认资源没有太大区别。Device-Plugin以Daemonset的方式在主机侧运行,并且通过一个Socket文件与Kubelet进行通信,从而通过Kubelet给K8s上报相关信息。部署了该Daemonset的主机节点在k8s看来会包含由Device-Plugin注册的硬件资源。Device-Plugin总的原理如下:

(图片来源:https://medium.com/@Alibaba_Cloud)

首先,Device-Plugin需要向K8s注册该资源,注册机制通过实现以下RPC接口得以实现:

service Registration {  rpc Register(RegisterRequest) returns (Empty) {}}

在详细的rpc调用中,该接口会上报socket名称、Device-Plugin的Api Version等信息,当然,更重要的是它会上报ResourceName,该ResourceName会被K8s登记为该自定义设备的名称,而名称的规范是vendor-domain/resource,例如,Nvidia的GPU就被定义为nvidia.com/gpu,在用户申请资源时,就需要使用该名称。例如,在创建POD的资源配置里,需要这样指定该资源:

apiVersion: v1kind: Podmetadata:  name: demo-podspec:  containers:    - name: demo-container-1      image: k8s.gcr.io/pause:2.0      resources:        limits:          nvidia.com/gpu: 2 

注册之后,Device-Plugin还需要上报主机侧的设备的数量和状态,例如,如果主机节点上有8块GPU卡,Device-Plugin会将该数量的资源数量和资源id列表告知K8s。此外,当有Pod向K8s申请该资源时,K8s会从上报的id列表中按照一定策略返回满足用户需求的资源数量的id序列,当该id列表返回给Device-Plugin,再由Device-Plugin根据一定策略映射到真正的资源设备。以上的过程主要由以下的RPC调用实现的:

  service DevicePlugin {        rpc ListAndWatch(Empty) returns (stream ListAndWatchResponse) {}        rpc Allocate(AllocateRequest) returns (AllocateResponse) {}  }

这里的ListAndWatch将由Device-Plugin调用NVML库获取主机侧的GPU设备和状态,并返回给k8s相应的设备列表。而Allocate将在容器创建被调用,用来返回一些能够使用主机上该资源的特殊配置,比如一些环境变量,再将这些信息给到Kubelet,并在容器启动的时候传给容器。对于Nvidia GPU而言,主要是前面的提到的环境变量NVIDIA_VISIBLE_DEVICES,这样容器启动的时候就能够挂载相应的GPU设备了。

值得注意的是,容器侧并没有任何gpu虚拟化或者显存分配的策略,所以Nvidia-Device-Plugin分配的粒度是单张卡,并且绝对的一一映射,即上报的GPU数量就是绝对的GPU数量,k8s侧负责资源的分配,再由Device-Plugin受K8s的返回的需要挂载GPU数量和ID,并将容器映射到实际的GPU设备上。

这样的方式在某些场景下是合理的,比如在强计算量任务的模式下,能够避免不同进程对GPU卡资源的争抢以致发生显存OOM等现象。但是在某些场景下缺会造成巨大的资源浪费。比如有某些容器环境仅仅是给到用户进行算法的开发和调试,这样的任务在绝大部分时间里并不会实际使用GPU,这种情况下能够让容器可以适当的共享GPU是有很大价值的,毕竟GPU的价格非常昂贵,我们需要提供共享机制最大化资源的使用。此外Nvidia-Device-Plugin并没有考虑GPU的亲和性,这有可能会让单容器多卡的容器遭遇较差的计算性能。这里会介绍我们的实现思路。

如何让不同容器共享GPU?

前面介绍过,Device-Plugin通过ListAndWatch接口上报GPU资源列表,那么自然而然,我们会想,如果能够伪造出更多虚拟的GPU ID给K8s,K8s在分配POD资源的时候返回虚拟的id,再由Device-Plugin映射回真实的id从而实现GPU卡的复用,就像虚拟内存地址到物理内存地址映射一样,而虚拟内存可以比物理内存大很多。是的,主要的思路就是这样,构造出虚拟的GPU设备id,在容器真正启动的时候再映射到真实的GPU设备。但是有一个非常重要的问题需要解决:怎么样保证GPU负载的大体上的平衡,而不会出现某些卡上绑定了太多的容器,而另一些卡上则没有任何容器?这个现象是有可能出现的,容器的寿命不一,不断的创建和删除容器极有可能会造成GPU资源分配的不均匀。所以虚拟id到真实id的映射不能够是通过一个简单的线性映射关系来决定,而是需要通过考虑在任务创建时的真实的GPU负载来动态的决定挂载GPU的id。

解决这个问题我们评估过两个方案:调用NVML获取GPU卡的实时状态,并选择负载较少的卡进行分配;借助外部数据库存放每个节点的GPU负载信息,Device-Plugin在Allocate的时候调用Redis的信息查看负载情况。我们最终采用的是第二种方法,因为NVML只能够查询进程占用、资源占用的情况,如果一个容器绑定了一块GPU卡,但容器里面并没有任何进程使用GPU,那么用NVML并不能查看真实的容器绑定关系。我们现阶段还是希望把负载局限在卡上绑定的容器数,而不是真实的GPU使用率。

当决定采用借助于一个外部的Redis数据库的方案,用来存放每个节点的实时动态的状态,我们会在redis中为每一个节点维护一个单独的map,记录每个GPU id上分配的容器数量和任务名称。当Device-Plugin每次决定Allocate分配时,会去查询Redis中查询该节点下各个GPU id的容器负载,选择最低的部分进行绑定,并且将相应的GPU id上的容器负载增加1。当资源释放时,Device-Plugin并不能知道释放的消息,我们通过K8s的调度服务的Informer机制,在自定义的Informer中捕捉到释放的POD的节点信息和任务名称,并以此并将Redis中相应的GPU id的资源数减去1。通过这种方式维护和监控着GPU资源的分配信息。这里仅仅介绍解决的思路,具体细节不再展开。

如何让多卡任务绑定亲和性高的卡?

GPU的通道亲和性在多卡训练任务中非常重要,因为不同的连接介质对卡与卡之间的数据传输速度影响非常大。以下是一个典型的8卡GPU的卡间通道拓扑图。可以看出有的卡之间是通过Nvlink(NV1等)相连,有的是通过PCIe(PIX)相连。

而不同的GPU通道会导致完全不一样的数据传输性能,通常它们之间的速度传输能相差很多倍,例如,Nvlink可以达到几十GB/s,而PCIe通常只有10 GB/s左右的吞吐性能。下图是Nvidia Tesla P100的系列的直观的连通拓扑图和通道传输性能:

(图片来源:https://www.nvidia.com)

正如前面所说,Nvidia-Device-Plugin并没有考虑通道的亲和性,也就是说在一个单容器双卡的容器中,通过K8s的调度极有可能将两个通过PCIe连接的卡绑定到一起,即便有在同一个Nvlink通道的卡的存在,这显然是不合理的。高亲和性和容器负载均衡有时会是相互矛盾的需求,如果追求绝对的亲和性,那么可能要牺牲容器任务的负载均衡。我们采用的算法策略是尽量让这两个指标取得一个均衡。

如果不考虑真实的GPU任务负载,单纯的让高亲和性的卡绑定到一起是比较容易实现的。类似于共享GPU实现的思路,在多卡绑定的任务重,我们可以在Device-Plugin里面调用NVML,获得GPU卡的连接拓扑,从而知道它们之间的亲和性关系。然后当Allocate的时候,选择让高亲和性通道间的GPU卡分配到一起即可。但是,如果考虑到高亲和性的卡中间有部分的容器任务负载非常高,那么这个时候可能要高负载的影响。比较合理的方式是使用评分函数,按照一定策略给不同的可选组合评分,选择得分最高的组合。但是我们采用较简单和直接的策略:首先选出负载最小的一个GPU id,再选择跟这个id在同一高亲和性通道的GPU卡,并挑选其中任务负载最小的卡进行绑定。具体的细节不再展开了,主要是对NVML库的调用,可以拿到主机的通道拓扑,剩下的工作是顺理成章的。

总  结

本文简单介绍了Docker对Nvidia GPU的支持方案以及K8s的Device-Plugin机制。并且针对现有的Nvidia-Device-Plugin的某些场景缺陷提供解决思路。主要是针对GPU卡的任务间共享和通道亲和性的优化。然而,这些改造都是要看场景的,有的场景值得和迫切需要这么做,但是有的场景却非常不适合。这样的改造会增加外部依赖并且让Nvidia-Device-Plugin的GPU卡的绑定策略变得更加复杂,所以我个人强烈建议只有在必要的时候进行这些改造。而平台的交互式测试、验证场景,正是这样改造的场景和动力。

本文转载自:帆一尚行

About Rancher Labs

Rancher Labs由硅谷云计算泰斗、CloudStack之父梁胜创建,致力于打造创新的开源软件,帮助企业在生产环境中运行容器与Kubernetes。旗舰产品Rancher是一个开源的企业级Kubernetes平台,是业界首个且唯一可以管理所有云上、所有发行版、所有Kubernetes集群的平台。解决了生产环境中企业用户可能面临的基础设施不同的困境,改善Kubernetes原生UI易用性不佳以及学习曲线陡峭的问题,是企业落地Kubernetes的不二之选。

Rancher在全球拥有超过一亿的下载量,超过20000家企业客户。全球知名企业如中国人寿、华为、中国平安、民生银行、兴业银行、上汽集团、海尔、米其林、天合光能、丰田、本田、霍尼韦尔、金风科技、普华永道、海南航空、厦门航空、恒大人寿、中国太平、巴黎银行、美国银行、HSCIS恒生指数、中国水利、暴雪、CCTV等均是Rancher的付费客户。

点击阅读原文,锁定大会席位!

↓↓↓
Measure
Measure
Related Notes
Get a free MyMarkup account to save this article and view it later on any device.
Create account

End User License Agreement

Summary | 40 Annotations
Nvidia的GPU的利用
2020/06/30 06:34
Nvidia公司为Docker写的Runtime
2020/06/30 06:34
容器粒度来管理和使用GPU要比从主机角度容易很多
2020/06/30 06:34
借助Kubernetes来管理Nvidia-Docker,使得GPU任务的分配更加简单和合理,目前已成为几乎所有主流的AI算力平台的方案
2020/06/30 06:34
Device-Plugin的方式来增加对默认资源(CPU,Memory等)之外的设备
2020/06/30 06:38
每一块GPU同时最多只能被一个容器使用
2020/06/30 06:38
没有考虑GPU卡之间的通道亲和性
2020/06/30 06:38
GPU调度的通用流程
2020/06/30 06:38
容器GPU挂载
2020/06/30 06:39
K8s中将GPU作为拓展资源调度的Device-Plugin机制
2020/06/30 06:39
原生Nvidia-Device-Plugin
2020/06/30 06:39
Nvidia-Docker来做AI系统环境已经是非常主流的做法
2020/06/30 06:39
Containerd则包装了Runc和其它功能如生命周期管理等,以Daemon的形式运行在主机
2020/06/30 06:40
GPU的支持放入一个兼容OCI标准的运行时库拓展libnvidia-container中
2020/06/30 06:40
Runc会调用一个叫做nvidia-container-runtime-hook的hook
2020/06/30 06:40
libnvidia-container暴露的接口进行交互
2020/06/30 06:40
主机侧的Nvidia-Driver
2020/06/30 06:41
多个Nvidia-Docker可以挂载同一个GPU
2020/06/30 06:41
环境变量来指定将要挂载的GPU设备
2020/06/30 06:49
NVIDIA_VISIBLE_DEVICES=0,1
2020/06/30 06:49
RDMA设备
2020/06/30 06:49
AMD GPU等
2020/06/30 06:49
Device-Plugin机制本质上是一个RPC服务
2020/06/30 06:49
K8s注册该资源
2020/06/30 06:50
vendor-domain/resource
2020/06/30 06:50
llocate将在容器创建被调用,用来返回一些能够使用主机上该资源的特殊配置,比如一些环境变量,再将这些信息给到Kubelet,并在容器启动的时候传给容器
2020/06/30 06:51
没有任何gpu虚拟化
2020/06/30 06:51
显存分配的策略
2020/06/30 06:51
提供共享机制最大化资源的使用
2020/06/30 06:51
没有考虑GPU的亲和性,
2020/06/30 06:51
伪造出更多虚拟的GPU ID给K8s
2020/06/30 06:51
GPU负载的大体上的平衡
2020/06/30 06:52
虚拟id到真实id的映射不能够是通过一个简单的线性映射关系
2020/06/30 06:52
任务创建时的真实的GPU负载来动态的决定挂载GPU的id
2020/06/30 06:52
NVML获取GPU卡的实时状态
2020/06/30 06:53
外部数据库存放每个节点的GPU负载信息
2020/06/30 06:53
Allocate的时候调用Redis的信息查看负载情况
2020/06/30 06:53
负载局限在卡上绑定的容器数
2020/06/30 06:53
GPU卡的连接拓扑
2020/06/30 06:55
评分函数
2020/06/30 06:55