如何在K8s集群中管理与使用GPU

背景

随着人工智能的兴起,GPU作为重要的智算算力类型愈发受到重视,而Kubernetes(k8s)作为业界主流的集群管理系统,如何方便管理、使用GPU也是其需要解决的一大问题,故此收集整理了K8s管理与使用GPU的相关资料以学习。

物理机如何使用GPU

如果给一台普通的物理机,例如我们日常用的笔记本电脑应该如何使用GPU呢。其主要涉及到两个插件的安装,分别是Nvidia DriverCUDA Toolkit

Nvidia Driver

Nvidia Driver就是GPU驱动,其与其他驱动类似,其主要作用是作为操作系统与GPU硬件之间沟通的桥梁,它需要负责将GPU复杂的硬件功能抽象为标准化接口,方便操作系统和软件调用,并能把GPU硬件的反馈结果传递给操作系统或应用程序。

Cuda Toolkit

Cuda toolkit是NVIDIA提供的一个开发工具集,包含了一系列用于GPU编程的工具和库。其主要由以下组件组成:

  • Compiler: CUDA-C和CUDA-C++编译器NVCC位于bin/目录中。它建立在NVVM优化器之上,而NVVM优化器本身构建在LLVM编译器基础结构之上。希望开发人员可以使用nvm/目录下的Compiler SDK来直接针对NVVM进行开发。
  • Tools: 提供一些像profiler,debuggers等工具,这些工具可以从bin/目录中获取
  • Libraries: 下面列出的部分科学库和实用程序库可以在lib/目录中使用(Windows上的DLL位于bin/中),它们的接口在include/目录中可获取。
    • cudart: CUDA Runtime
    • cudadevrt: CUDA device runtime
    • cupti: CUDA profiling tools interface
    • nvml: NVIDIA management library
    • nvrtc: CUDA runtime compilation
    • cublas: BLAS (Basic Linear Algebra Subprograms,基础线性代数程序集)
    • cublas_device: BLAS kernel interface
  • Runtime Api:提供GPU访问的接口,包括:
    • CUDA Runtime API: 提供简单易用的高层接口,简化GPU的初始化和资源管理。
    • CUDA Driver API: 更底层的接口,提供对GPU的精细控制,适合需要自定义优化的高级用户。
  • CUDA Samples: 演示如何使用各种CUDA和library API的代码示例。可在Linux和Mac上的samples/目录中获得,Windows上的路径是C:\ProgramData\NVIDIA Corporation\CUDA Samples中。在Linux和Mac上,samples/目录是只读的,如果要对它们进行修改,则必须将这些示例复制到另一个位置。

说明

安装完以上插件后就可以使用GPU了,我们可以直接使用CUDA来编程也可以利用Pytorch、TensorFlow等机器学习库来间接使用GPU,在使用GPU时,其整体的调用链如下图所示:

GPU调用链

Docker如何使用GPU

配置nvidia-container-runtime

正常创建一个容器的流程是这样的:

docker创建容器流程

简单来说主要有以下这些步骤:

  1. 用户命令传递: CLI 将用户命令解析并使用 HTTP 或 Unix Socket 与 dockerd 通信。
  2. 调度与管理: dockerd 解析命令并检查、拉取镜像,再调用 containerd 创建一个新的容器任务,准备容器的元数据和配置(如挂载点、网络设置、环境变量等),并为每个任务创建一个 containerd-shim 进程
  3. 隔离与启动: containerd-shim 启动并调用 runc 创建隔离环境,runc 从 containerd 提供的配置中读取容器规格,包括文件系统挂载、网络命名空间、cgroups 配置(限制 CPU、内存等资源),并配置PID、Network、Mount Namespace级别的隔离,再配置 Cgroups,限制资源使用,设置 rootfs,将镜像内容挂载为容器的根文件系统。
  4. 容器运行: runc 启动用户指定的进程,容器进入运行状态。

而为了能够让容器也能直接使用GPU,我们就需要修改创建容器的关键runtime为nvidia-container-runtime,而我们一般都通过NVIDIA Container Toolkit来安装nvidia-container-runtime。旧版本修改runtime为nvidia-container-runtime是需要手动在etc/docker/daemon.json中增加配置,指定使用 nvidia 的 runtime,如下:

1
2
3
4
5
6
"runtimes": {
"nvidia": {
"args": [],
"path": "nvidia-container-runtime"
}
}

新版 toolkit 带了一个nvidia-ctk 工具,执行以下命令即可一键配置:

1
sudo nvidia-ctk runtime configure --runtime=docker

然后重启 Docker 即可,再创建使用GPU的容器时,只需要加入--gpu参数即可,如docker run --rm --gpus all nvidia/cuda:12.0.1-runtime-ubuntu22.04 nvidia-smi

说明

修改runtime后,如下图所示,containerd-shim会调用指定的运行时nvidia-container-runtime。nvidia-container-runtime相比于默认的runc多实现了nvidia-container-runime-hook,该hook是在容器启动后(Namespace已创建完成),容器自定义命令(Entrypoint)启动前执行。当检测到NVIDIA_VISIBLE_DEVICES环境变量时,会调用libnvidia-container挂载GPU Device和CUDA Driver。如果没有检测到NVIDIA_VISIBLE_DEVICES就直接执行默认的runc。

docker创建GPU容器流程

在Docker 环境中的 CUDA 调用的整体层级如下图所示,NVIDIA 将原来 CUDA 应用依赖的API环境划分为两个部分:

  • 驱动级API:由libcuda.so.major.minor动态库和内核module提供支持,图中表示为CUDA Driver,它必须在宿主机上就配置好,且只能有一个版本。
  • 非驱动级API:由动态库libcublas.so等用户空间级别的API(算是对驱动级API的一种更高级的封装)组成,图中表示为CUDA Toolkit,直接存在在各个容器中,各个容器中的CUDA Toolkit的版本也可以不同。

docker中的CUDA调用

K8s如何使用GPU

通过上述说明,我们可以手动在宿主机中起一个使用GPU的容器,但是对于k8s管理的大规模集群,我们还需要做到可以让k8s感知到有哪些GPU可以使用,可以通过k8s的pod来创建使用GPU的容器。

手动配置

在k8s中使用GPU资源涉及到的一个关键组件就是NVIDIA Device Plugin

Device plugin是k8s 用于管理和调度容器中设备资源的一种插件机制,它可以将物理设备(如 GPU、FPGA 等)暴露给容器,从而提供更高级别的资源管理和调度能力。它由各个硬件对应的厂商提供,其主要是通过DeamonSet部署到各个主机上,然后上报给kubelet对应的硬件资源的情况,再上报给master。

当我们安装了NVDIA的device plugin后再次查看node的可分配资源就可以看到GPU相关的信息:

1
2
3
4
5
6
7
8
9
root@test:~# k describe node test|grep Capacity -A7
Capacity:
cpu: 48
ephemeral-storage: 460364840Ki
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 98260824Ki
nvidia.com/gpu: 2
pods: 110

可以看到,除了常见的 cpu、memory 之外,还有nvidia.com/gpu, 这个就是GPU资源,数量为 2 说明我们有两张 GPU。当我们要为pod分配GPU资源的时候也比较简单,只需要在resources.limits中加入nvidia.com/gpu: 1,就可以为pod申请一块GPU资源了。

具体实现

而具体来说NVDIA device plugin主要是通过实现ListAndWatch 接口来上报节点上的GPU数量,实现Allocate接口, 支持分配GPU的行为。

这部分的关键源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ListAndWatch lists devices and update that list according to the health status
func (plugin *NvidiaDevicePlugin) ListAndWatch(e *pluginapi.Empty, s pluginapi.DevicePlugin_ListAndWatchServer) error {
s.Send(&pluginapi.ListAndWatchResponse{Devices: plugin.apiDevices()})

for {
select {
case <-plugin.stop:
return nil
case d := <-plugin.health:
// 收到某个设备有健康问题,标志该设备不健康
// FIXME: there is no way to recover from the Unhealthy state.
d.Health = pluginapi.Unhealthy
log.Printf("'%s' device marked unhealthy: %s", plugin.rm.Resource(), d.ID)
// 重新发送新的可用的device列表
s.Send(&pluginapi.ListAndWatchResponse{Devices: plugin.apiDevices()})
}
}
}

// Allocat主要是分配显卡,给容器指定要附加的NVIDIA_VISIBLE_DEVICES环境变量
func (plugin *NvidiaDevicePlugin) Allocate(ctx context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
responses := pluginapi.AllocateResponse{}
// 为每个请求分配设备
for _, req := range reqs.ContainerRequests {

if plugin.config.Sharing.TimeSlicing.FailRequestsGreaterThanOne && rm.AnnotatedIDs(req.DevicesIDs).AnyHasAnnotations() {
if len(req.DevicesIDs) > 1 {
return nil, fmt.Errorf("request for '%v: %v' too large: maximum request size for shared resources is 1", plugin.rm.Resource(), len(req.DevicesIDs))
}
}
// 判断一下申请的设备ID是不是自己所管理的,也就是所拥有的设备,也就是校验是不是自己注册的那些设备
for _, id := range req.DevicesIDs {
if !plugin.rm.Devices().Contains(id) {
return nil, fmt.Errorf("invalid allocation request for '%s': unknown device: %s", plugin.rm.Resource(), id)
}
}

response := pluginapi.ContainerAllocateResponse{}
// 将注册时的设备ID转换为具体的gpu id
ids := req.DevicesIDs
deviceIDs := plugin.deviceIDsFromAnnotatedDeviceIDs(ids)
// 将分配的设备信息保存到Env里面去,后续docker的runC将设备信息以环境变量的形式注入到容器
if *plugin.config.Flags.Plugin.DeviceListStrategy == spec.DeviceListStrategyEnvvar {
response.Envs = plugin.apiEnvs(plugin.deviceListEnvvar, deviceIDs)
}
if *plugin.config.Flags.Plugin.DeviceListStrategy == spec.DeviceListStrategyVolumeMounts {
response.Envs = plugin.apiEnvs(plugin.deviceListEnvvar, []string{deviceListAsVolumeMountsContainerPathRoot})
response.Mounts = plugin.apiMounts(deviceIDs)
}
if *plugin.config.Flags.Plugin.PassDeviceSpecs {
response.Devices = plugin.apiDeviceSpecs(*plugin.config.Flags.NvidiaDriverRoot, ids)
}
if *plugin.config.Flags.GDSEnabled {
response.Envs["NVIDIA_GDS"] = "enabled"
}
if *plugin.config.Flags.MOFEDEnabled {
response.Envs["NVIDIA_MOFED"] = "enabled"
}

responses.ContainerResponses = append(responses.ContainerResponses, &response)
}

return &responses, nil
}

整个Kubernetes调度GPU的过程如下:

  1. GPU Device plugin 部署到GPU节点上,通过ListAndWatch接口,上报注册节点的GPU信息和对应的DeviceID。
  2. 当有声明nvidia.com/gpu的GPU Pod创建出现,调度器会综合考虑GPU设备的空闲情况,将Pod调度到有充足GPU设备的节点上。
  3. 节点上的kubelet启动Pod时,根据request中的声明调用各个Device plugin的allocate接口,由于容器声明了GPU。kubelet根据之前ListAndWatch接口收到的Device信息,选取合适的设备,DeviceID作为参数,调用GPU DevicePlugin的allocate接口。
  4. GPU device plugin接收到调用,将DeviceID转换为NVIDIA_VISIBLE_DEVICES环境变量,返回给kubelet
  5. kubelet收到返回内容后,会自动将返回的环境变量注入到容器中,并开始创建容器。
  6. 容器创建时,nvidia-container-runtime调用gpu-containers-runtime-hook根据容器的NVIDIA_VISIBLE_DEVICES环境变量,来决定这个容器是否为GPU容器,并且可以使用哪些GPU设备。如果没有携带NVIDIA_VISIBLE_DEVICES这个环境变量,那么就会按照普通的docker启动方式来启动。

使用GPU Operator安装

GPU Operator旨在简化在Kubernetes环境中使用GPU的过程,通过自动化的方式处理GPU驱动程序安装、Controller Toolkit、Device-Plugin 、监控等组件。
NVIDIA GPU Operator总共包含如下的几个组件:

  • NFD(Node Feature Discovery): 用于给节点打上某些标签,这些标签包括 cpu id、内核版本、操作系统版本、是不是GPU节点等,其中需要关注的标签是nvidia.com/gpu.present=true,如果节点存在该标签,那么说明该节点是GPU节点。
  • GFD(GPU Feature Discovery): 用于收集节点的GPU设备属性(GPU驱动版本、GPU型号等),并将这些属性以节点标签的方式透出。在k8s集群中以DaemonSet方式部署,只有节点拥有标签nvidia.com/gpu.present=true时,DaemonSet控制的Pod才会在该节点上运行。
    • 新版本 GFD迁移到了NVIDIA/k8s-device-plugin
  • NVIDIA Driver Installer:基于容器的方式在节点上安装 NVIDIA GPU驱动,在k8s集群中以DaemonSet 方式部署,只有节点拥有标签nvidia.com/gpu.present=true时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • NVIDIA Container Toolkit Installer:能够实现在容器中使用GPU设备,在k8s集群中以DaemonSet 方式部署,同样的,只有节点拥有标签nvidia.com/gpu.present=true时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • NVIDIA Device Plugin:NVIDIA Device Plugin 用于实现将GPU设备以 Kubernetes 扩展资源的方式供用户使用,在k8s集群中以DaemonSet 方式部署,只有节点拥有标签nvidia.com/gpu.present=true时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • DCGM Exporter:周期性的收集节点GPU设备的状态(当前温度、总的显存、已使用显存、使用率等)并暴露Metrics,结合Prometheus和Grafana使用。在k8s集群中以DaemonSet 方式部署,只有节点拥有标签nvidia.com/gpu.present=true时,DaemonSet 控制的Pod才会在该节点上运行。

首先是 GFD、NFD,二者都是用于发现 Node 上的信息,并以label形式添加到k8snode对象上,特别是GFD会添加nvidia.com/gpu.present=true标签表示该节点有GPU,只有携带该标签的节点才会安装后续组件。
然后则是Driver Installer、Container Toolkit Installer用于安装GPU驱动和container toolkit。
接下来这是device-plugin让k8s能感知到GPU资源信息便于调度和管理。
最后的exporter则是采集GPU监控并以Prometheus Metrics格式暴露,用于做GPU监控。

这里着重提及一下NVIDIA Driver InstallerNVIDIA Container Toolkit Installer是如何通过容器的方式来给主机安装对应的内容的。
其安装的主要方法还是通过hostPath挂载的方式来将相关的目录挂载进容器中,然后控制容器将对应的内容添加进目录里。而如果要将相关内容卸载,也是只需要将对应的容器删除,容器就会自动移除相应安装的内容。

GPU Operator虽然方便了安装但是仍然存在一些缺点:

  • Driver Installer 以DaemonSet 方式运行的,每个节点上运行的 Pod 都一样,但是镜像由 驱动版本+内核版本+操作系统版本拼接而成,因此需要集群中所有节点操作系统一致
  • NVIDIA Container Toolkit Installer 同样是以DaemonSet 方式运行的,另外安装时需要指定 Runtime,这也造成了集群的节点必须安装相同的 Container Runtime

思考

目前学习下来的一大感受就是GPU管理的粒度很粗,都是以整卡为单位进行划分,这势必会造成资源的浪费,如何结合GPU卡虚拟化来进行细粒度的分配感觉是一个很大的问题。同时它也没有刻画GPU卡之间、CPU与GPU卡之间的拓扑关系,而当前对于大模型训练其网络管理与优化是非常重要的。

参考资料

  1. https://www.lixueduan.com/posts/ai/01-how-to-use-gpu
  2. https://www.lixueduan.com/posts/ai/02-gpu-operator/
  3. https://www.aneasystone.com/archives/2023/12/scheduling-gpus-in-kubernetes.html
  4. https://blog.csdn.net/qq_43684922/article/details/127024933
  5. https://kubernetes.io/zh-cn/docs/concepts/extend-kubernetes/compute-storage-net/device-plugins/
  6. https://kubernetes.io/zh-cn/docs/tasks/manage-gpus/scheduling-gpus/
  7. https://blog.csdn.net/qq_43684922/article/details/127025776
  8. https://www.lixueduan.com/posts/kubernetes/21-device-plugin/#

如何在K8s集群中管理与使用GPU
http://example.com/2024/11/19/k8sUseGPU/
作者
滑滑蛋
发布于
2024年11月19日
许可协议