Jason Pan

GPU 容器化计算架构深度解析

潘忠显 / 2025-12-15


本文将以最基础的 CUDA 代码为例,由浅入深地解析在 Linux 宿主机上运行 GPU 容器时,GPU 软件栈的各个组件是如何分布、如何协作并实现资源共享的,以及如何最下化容器镜像。也欢迎查看基础文章《容器运行共享内核原理》。

一、运行最小的CUDA程序

首先,我们通过一个最小的 CUDA 程序来验证整个 GPU 软件栈是否能够连通和运行,并且理解 GPU 程序的工作原理

代码和解释

将后边的代码保存在名为 minimal.cu 的文件中,这个程序会启动一个简单的并行计算任务,并在 GPU 上打印一条 Hello from GPU thread 0! 的消息。如何理解这个程序以及「在GPU上打印一条消息」?

  1. 核心执行体(Kernel) :打印消息的 printf 指令位于一个 CUDA Kernel 函数内。该指令是由 GPU 上的线程并行执行的,而不是由 CPU 执行。
  2. 异步 I/O 处理 :GPU 并没有直接连接到显示器或终端的标准输出流。当 Kernel 中的线程执行 printf 时,数据不会立即打印出来。这些输出数据会被 CUDA 运行时在 GPU 的显存中进行缓冲 (Buffering)
  3. 同步与回传 :为了确保这些消息能被用户看到,程序必须执行一个同步操作,代码中的 cudaDeviceSynchronize()。这个指令会强制 CPU 等待 GPU 上的所有 Kernel 执行完毕,并在同步过程中将 GPU 缓冲区的打印数据回传 (Flush) 到宿主机(CPU)的标准输出流中,最终显示在终端上。

简而言之,这条简单的打印消息验证了从 CPU (Host) 启动指令,到 GPU (Device) 执行计算,再到 I/O 结果回传的整个复杂流程是完整且兼容的。

#include <iostream>
#include <cstdio>
#include <cuda_runtime.h>

__global__ void simpleKernel() {
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    if (tid == 0) {
        printf("Hello from GPU thread %d!\n", tid);
    }
}

int main() {
    std::cout << "Launching CUDA kernel from CPU..." << std::endl;

    // 1. Configure and Launch the Kernel
    simpleKernel<<<1, 1>>>();

    // 2. Check for CUDA errors immediately after the kernel launch
    cudaError_t err = cudaGetLastError();
    if (err != cudaSuccess) {
        std::cerr << "CUDA Error (Kernel Launch): " << cudaGetErrorString(err) << std::endl;
        return 1;
    }

    // 3. Synchronize the CPU with the GPU
    err = cudaDeviceSynchronize();
    if (err != cudaSuccess) {
        std::cerr << "CUDA Error (Synchronization): " << cudaGetErrorString(err) << std::endl;
        std::cerr << "Possible cause: CUDA version incompatibility between runtime and driver." << std::endl;
        return 1;
    }
    std::cout << "Kernel execution finished successfully." << std::endl;
    return 0;
}

编译与运行: 使用 nvcc 编译器将 .cu 文件编译成可执行程序,并运行:

nvcc minimal.cu -o minimal_cuda
./minimal_cuda

结果输出:

nvcc-minimal-cu

nvcc 的构建过程

我们上边使用了简单指令 nvcc 看上去用法跟使用 gcc 很像。nvcc 实际上是一个编译器驱动程序,它就是对 gcc/g++ 以及 NVIDIA 自己的设备端编译器的封装和协调。最终生成一个能够同时在 CPU 和 GPU 上运行的二进制文件

我们可以通过加上 -v 选项,来查看其构建程序的过程。它会执行以下关键步骤:

  1. 代码分离: nvcc 首先解析 minimal.cu 文件,识别出两部分代码:
    • 设备端代码 (Device Code):__global____device__ 等修饰的 CUDA Kernel 代码(例如我们的 simpleKernel)。
    • 主机端代码 (Host Code): 所有的普通 C/C++ 代码(例如 main() 函数和其中的 Kernel 启动指令)。
  2. 设备端编译 (NVIDIA 编译器):nvcc 使用 NVIDIA 自己的编译器前端将设备端代码编译成 PTX (Intermediate Code) 或 SASS (GPU Machine Code)。
  3. 主机端编译: nvcc 此时会调用系统上配置的 gccg++ 来编译主机端代码(main() 函数)
  4. 链接nvcc 调用系统的链接器 (ld) 来链接所有部分:编译好的主机对象文件、封装好的设备代码,以及所需的 CUDA 库(例如静态链接的 libcudart.a)。

process-of-building-cuda-program

依赖 libcudart 和 libcudadevrt

上边最后链接的时候,静态链接了两个看起来很相似的库 cudart 和 cudadevrt,我用高亮标注了一下。他们两个都是运行时库,区别在于:

两者都是静态链接,以确保 minimal_cuda 文件的高度自包含和可移植性。

依赖 libcuda.so

其实这个二进制文件,还依赖另外一个重要的库 libcuda.so,这个作用我们后边再介绍,现在就是看看依赖。

但是构建过程中没有,通过 ldd minimal_cuda 也看不到,因为 ldd 只能识别 静态声明 的依赖。而该库是在前面介绍的 libcudart.so在程序启动后,会程序性地调用 dlopen() ,去查找并加载的。

dlopen() 可以通过 strace 指令捕获到,我们看到这里实际路径是/lib64/libcuda.so.1,该文件链接到了 libcuda.so.525.105.17:

host-libcuda-so

二、GPU和显卡

前面的例子中,我们反复提到了「在CPU上运行」、「在GPU上运行」、「GPU Kernel」等。

其实这里需要澄清一个概念,上边有些地方表述不严谨,用 GPU 代指了显卡。

GPU显卡还是有差别的,尤其是独立显卡。

什么是独立显卡

nvidia-a10

独立显卡 可以被视为一个独立的计算机系统(协处理器)最为恰当,因为它具备了独立运行所需的全部硬件组件:

独立显卡不仅仅是物理上的独立,更重要的是功能和软件上的相对独立和隔离:

因此,在我们讨论的 CUDA 架构中,所有涉及 内存分配 (cudaMalloc) 和 异步执行 的操作,都是基于这种 独立显卡 提供的完整计算子系统来实现的。

查看显卡信息

nvidia-smi (NVIDIA System Management Interface) 是 NVIDIA 驱动层提供的一个核心工具,用于监控和管理 GPU 硬件状态。

下边图中的展示的显卡信息包括:

nvidia-smi-a10

三、宿主机上运行原理

前面我们已经在 Linux 宿主机上的验证了 GPU 软件栈是完整的。这个所谓的「栈」实际有三个层组成,其实通过前面的介绍,也很容易分辨和理解:

兼容性要求

我们在前面的文章中介绍过 Linux 内核和 Glibc 之间的版本兼容关系。内核版本更新,可以兼容更多的Glibc的版本。体现了软件栈中核心基础设施必须提供向后兼容性的原则。

本文中的驱动层和运行层的版本兼容,也有类似的关系。驱动层是核心,它决定了运行层能支持的最高版本:应用程序的 CUDA 版本必须小于或等于驱动支持的 CUDA 版本,才能确保兼容性。

NVIDIA 驱动程序通常设计为向后兼容。前面我们通过 nvidia-smi 看到的驱动支持的 CUDA 版本12.0是上限。只有驱动支持这个版本,上层的 libcudart.so(例如 CUDA 11.8)才能通过驱动提供的接口正常工作。而反过来,如果驱动只支持到 12.0,那跑一个 13.0 编译的程序,就会失败。

四、容器中运行原理

前面介绍了物理机上要运行使用 GPU 的程序,有三个层次。理解之后,我们容易理解,在容器化环境中,可以包含应用层和运行层

但是到了驱动层这里,我们列出它由内核模式驱动和用户模式驱动两部份组成。我们前文也介绍过了,容器间是「共享内核的」,所以这里的内核模式驱动,明确是在宿主机上的。

只剩下一个用户模式驱动(libcuda.so),本节就来看看它具体是如何工作的。

最小GPU程序在容器中运行

我们依然还是通过最小的GPU程序运行起来,来验证容器中使用GPU的完整性。

前面通过 nvidia-smi 看到了驱动支持的 CUDA 12.0。因此,我们这里直接使用英伟达的官方镜像,进行构建和运行:

docker run --gpus all -i -v $PWD:/data/code \
  -t nvidia/cuda:12.0.0-devel-centos7 /bin/bash

同样的方法,我们构建和运行(为了区分二进制文件,我这里加了个 _docker 后缀):

nvcc-inside-container

容器内依赖的库

首先,检查驱动核心 (libcuda.so),镜像中有文件 /usr/lib64/libcuda.so.1,所以我们的程序可以加载到这个动态库。该文件是个软连接,软连接到 /usr/lib64/libcuda.so.525.105.17,竟然跟宿主机上的版本一模一样

这不是巧合,恰恰是是 Linux 内核 VFS (Virtual File System) 的一个功能——绑定挂载 (Bind Mount)。它将宿主机上的一个文件(或目录)原封不动地映射到容器内的指定路径上。对于容器内的进程而言,它看起来就像一个完全正常的、位于本地文件系统上的文件。它的权限、大小、时间戳都与宿主机上的原始文件一模一样。

直接 ls 是看不出来的,可以使用 stat 指令查看(左边为容器内,右边为宿主机),两个输出中文件的 InodeDevice ID 完全一致,证明它们是同一个文件系统对象,从而确定是绑定挂载。

same-libcuda-so

或者直接使用 findmnt 命令,可以直接查看文件系统的挂载点信息:

findmnt-libcuda

然后,检查运行时库 (libcudart.so),在容器内执行:

ls /usr/local/cuda/targets/x86_64-linux/lib/libcudart.so.12

这个文件是容器镜像自身安装的,通过 findmnt 也可以看出是容器内部的文件:

findmnt-libcudart

可见,libcudart.so 是来自 nvidia/cuda:*-runtime 基础镜像。

通过库我们也可以看出:驱动核心留在宿主机,容器只负责提供应用环境。

核心机制:容器“借用”GPU

容器访问 GPU 的能力完全依赖于 NVIDIA Container Toolkit,这是一个专门为 Docker/Podman 等容器运行时设计的扩展插件。

我们可以通过检查 Docker 的配置,来查看是否有该插件(图中的 nvidia 运行时配置):

daemon-json

隔离穿透:设备节点映射

宿主机内核驱动在 /dev 目录下创建了 GPU 对应的设备节点(如 /dev/nvidia0)。

当我们使用 docker run --gpus all 命令运行容器时,NVIDIA 运行时会自动识别这些设备节点,并将其映射到容器内部。容器内的进程可以直接通过 /dev/nvidia0 向 GPU 发送 I/O 请求。

gpu-in-and-out-docker

为什么必须挂载库

为什么 libcuda.so 必须从宿主机以绑定挂载 (Bind Mount) 的方式,将这些宿主机上的库文件“注入”到容器的文件系统中。

在 NVIDIA 的驱动架构中,用户模式驱动库 (libcuda.so.XXX) 充当了应用程序(通过 CUDA Runtime)进入 内核模式驱动 的门户。

由于两者需要以极高的效率和精度进行通信用户库的版本必须与内核驱动的版本高度匹配。如果版本不匹配,用户库发出的命令可能与内核驱动期望的数据结构不符,轻则导致性能下降,重则直接导致 Kernel 崩溃或程序错误。

我们的例子中,宿主机正在运行 Driver Version: 525.105.17,它依赖的 用户模式驱动库 也必须是 libcuda.so.525.105.17

因此 NVIDIA Container Toolkit 会在容器启动运行时:

  1. 查询宿主机内核中运行的是 525.105.17 版本的驱动。
  2. 在宿主机的文件系统上找到这个版本对应的用户库文件。
  3. 注入容器:使用 绑定挂载,将这个精确匹配的 libcuda.so.525.105.17 文件(及其软链接)映射到容器内,供容器中的 libcudart.so 使用。

这样,容器内的程序才能通过一个版本匹配的、完整的软件栈,安全、稳定地访问 GPU 硬件。

CUDA Runtime (libcudart.so)库有一定的兼容性容忍:应用程序链接的 Runtime 版本低于驱动支持的最高 CUDA 版本即可,因此无需绑定挂载。

五、最小化运行容器

尽管缩小容器镜像和容器使用 GPU 原理关系不大,还是借此机会介绍一下——这也是相当实用的工程技巧,原理也非常简单。

之前的模型服务,是一个流水线,使用同一个镜像进行构建和运行。每次服务拉取镜像有 7G 多,耗时五分钟多,这样效率会很低。

通过前面我们的实验,其实可以看到,Nvidia 提供的官方镜像有两个,一个是 devel 一个是 runtime。前者是用于构建,而后者是用于运行。可以看到这两个镜像大小差距非常大(超过一倍多差距):

image-size-of-devel-and-runtime

devel 镜像用于构建,会比 runtime 镜像多出很多内容:

因此,我们用类似的思路,去改造我们的流水线,将构建的镜像进行简化作为运行镜像。

启动镜像缩小了一半,启动时间也减少了近一半。

minial-runtime-container

总结

本文通过一个最小的 CUDA 程序,深入解析了在 Linux 宿主机和容器化环境中运行 GPU 计算的完整软件栈架构。 我们理解了:

希望对大家理解容器化 GPU 计算的原理有所帮助!