背景
随着人工智能的兴起,GPU作为重要的智算算力类型愈发受到重视,而Kubernetes(k8s)作为业界主流的集群管理系统,如何方便管理、使用GPU也是其需要解决的一大问题,故此收集整理了K8s管理与使用GPU的相关资料以学习。
物理机如何使用GPU
如果给一台普通的物理机,例如我们日常用的笔记本电脑应该如何使用GPU呢。其主要涉及到两个插件的安装,分别是Nvidia Driver和CUDA 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时,其整体的调用链如下图所示:
Docker如何使用GPU
配置nvidia-container-runtime
正常创建一个容器的流程是这样的:
简单来说主要有以下这些步骤:
- 用户命令传递: CLI 将用户命令解析并使用 HTTP 或 Unix Socket 与 dockerd 通信。
- 调度与管理: dockerd 解析命令并检查、拉取镜像,再调用 containerd 创建一个新的容器任务,准备容器的元数据和配置(如挂载点、网络设置、环境变量等),并为每个任务创建一个 containerd-shim 进程
- 隔离与启动: containerd-shim 启动并调用 runc 创建隔离环境,runc 从 containerd 提供的配置中读取容器规格,包括文件系统挂载、网络命名空间、cgroups 配置(限制 CPU、内存等资源),并配置PID、Network、Mount Namespace级别的隔离,再配置 Cgroups,限制资源使用,设置 rootfs,将镜像内容挂载为容器的根文件系统。
- 容器运行: runc 启动用户指定的进程,容器进入运行状态。
而为了能够让容器也能直接使用GPU,我们就需要修改创建容器的关键runtime为nvidia-container-runtime,而我们一般都通过NVIDIA Container Toolkit来安装nvidia-container-runtime。旧版本修改runtime为nvidia-container-runtime是需要手动在etc/docker/daemon.json
中增加配置,指定使用 nvidia 的 runtime,如下:
"runtimes": {"nvidia": {"args": [],"path": "nvidia-container-runtime"}}
新版 toolkit 带了一个nvidia-ctk 工具,执行以下命令即可一键配置:
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 环境中的 CUDA 调用的整体层级如下图所示,NVIDIA 将原来 CUDA 应用依赖的API环境划分为两个部分:
- 驱动级API:由libcuda.so.major.minor动态库和内核module提供支持,图中表示为CUDA Driver,它必须在宿主机上就配置好,且只能有一个版本。
- 非驱动级API:由动态库libcublas.so等用户空间级别的API(算是对驱动级API的一种更高级的封装)组成,图中表示为CUDA Toolkit,直接存在在各个容器中,各个容器中的CUDA Toolkit的版本也可以不同。
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相关的信息:
root@test:~# k describe node test|grep Capacity -A7
Capacity:cpu: 48ephemeral-storage: 460364840Kihugepages-1Gi: 0hugepages-2Mi: 0memory: 98260824Kinvidia.com/gpu: 2pods: 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的行为。
这部分的关键源码如下:
// 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 nilcase d := <-plugin.health:// 收到某个设备有健康问题,标志该设备不健康// FIXME: there is no way to recover from the Unhealthy state.d.Health = pluginapi.Unhealthylog.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 idids := req.DevicesIDsdeviceIDs := 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的过程如下:
- GPU Device plugin 部署到GPU节点上,通过ListAndWatch接口,上报注册节点的GPU信息和对应的DeviceID。
- 当有声明
nvidia.com/gpu
的GPU Pod创建出现,调度器会综合考虑GPU设备的空闲情况,将Pod调度到有充足GPU设备的节点上。 - 节点上的kubelet启动Pod时,根据request中的声明调用各个Device plugin的allocate接口,由于容器声明了GPU。kubelet根据之前ListAndWatch接口收到的Device信息,选取合适的设备,DeviceID作为参数,调用GPU DevicePlugin的allocate接口。
- GPU device plugin接收到调用,将DeviceID转换为NVIDIA_VISIBLE_DEVICES环境变量,返回给kubelet
- kubelet收到返回内容后,会自动将返回的环境变量注入到容器中,并开始创建容器。
- 容器创建时,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
- 新版本 GFD迁移到了
- 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 Installer和NVIDIA Container Toolkit Installer是如何通过容器的方式来给主机安装对应的内容的。
其安装的主要方法还是通过hostPath挂载的方式来将相关的目录挂载进容器中,然后控制容器将对应的内容添加进目录里。而如果要将相关内容卸载,也是只需要将对应的容器删除,容器就会自动移除相应安装的内容。
GPU Operator虽然方便了安装但是仍然存在一些缺点:
- Driver Installer 以DaemonSet 方式运行的,每个节点上运行的 Pod 都一样,但是镜像由 驱动版本+内核版本+操作系统版本拼接而成,因此需要集群中所有节点操作系统一致。
- NVIDIA Container Toolkit Installer 同样是以DaemonSet 方式运行的,另外安装时需要指定 Runtime,这也造成了集群的节点必须安装相同的 Container Runtime。
思考
目前学习下来的一大感受就是GPU管理的粒度很粗,都是以整卡为单位进行划分,这势必会造成资源的浪费,如何结合GPU卡虚拟化来进行细粒度的分配感觉是一个很大的问题。同时它也没有刻画GPU卡之间、CPU与GPU卡之间的拓扑关系,而当前对于大模型训练其网络管理与优化是非常重要的。
参考资料
- https://www.lixueduan.com/posts/ai/01-how-to-use-gpu
- https://www.lixueduan.com/posts/ai/02-gpu-operator/
- https://www.aneasystone.com/archives/2023/12/scheduling-gpus-in-kubernetes.html
- https://blog.csdn.net/qq_43684922/article/details/127024933
- https://kubernetes.io/zh-cn/docs/concepts/extend-kubernetes/compute-storage-net/device-plugins/
- https://kubernetes.io/zh-cn/docs/tasks/manage-gpus/scheduling-gpus/
- https://blog.csdn.net/qq_43684922/article/details/127025776
- https://www.lixueduan.com/posts/kubernetes/21-device-plugin/#