GPU 容器化计算架构深度解析
潘忠显 / 2025-12-15
本文将以最基础的 CUDA 代码为例,由浅入深地解析在 Linux 宿主机上运行 GPU 容器时,GPU 软件栈的各个组件是如何分布、如何协作并实现资源共享的,以及如何最下化容器镜像。也欢迎查看基础文章《容器运行共享内核原理》。
一、运行最小的CUDA程序
首先,我们通过一个最小的 CUDA 程序来验证整个 GPU 软件栈是否能够连通和运行,并且理解 GPU 程序的工作原理。
代码和解释
将后边的代码保存在名为 minimal.cu 的文件中,这个程序会启动一个简单的并行计算任务,并在 GPU 上打印一条 Hello from GPU thread 0! 的消息。如何理解这个程序以及「在GPU上打印一条消息」?
- 核心执行体(Kernel) :打印消息的
printf指令位于一个 CUDA Kernel 函数内。该指令是由 GPU 上的线程并行执行的,而不是由 CPU 执行。 - 异步 I/O 处理 :GPU 并没有直接连接到显示器或终端的标准输出流。当 Kernel 中的线程执行
printf时,数据不会立即打印出来。这些输出数据会被 CUDA 运行时在 GPU 的显存中进行缓冲 (Buffering)。 - 同步与回传 :为了确保这些消息能被用户看到,程序必须执行一个同步操作,代码中的
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 的构建过程
我们上边使用了简单指令 nvcc 看上去用法跟使用 gcc 很像。nvcc 实际上是一个编译器驱动程序,它就是对 gcc/g++ 以及 NVIDIA 自己的设备端编译器的封装和协调。最终生成一个能够同时在 CPU 和 GPU 上运行的二进制文件。
我们可以通过加上 -v 选项,来查看其构建程序的过程。它会执行以下关键步骤:
- 代码分离:
nvcc首先解析minimal.cu文件,识别出两部分代码:- 设备端代码 (Device Code): 用
__global__、__device__等修饰的 CUDA Kernel 代码(例如我们的simpleKernel)。 - 主机端代码 (Host Code): 所有的普通 C/C++ 代码(例如
main()函数和其中的 Kernel 启动指令)。
- 设备端代码 (Device Code): 用
- 设备端编译 (NVIDIA 编译器):
nvcc使用 NVIDIA 自己的编译器前端将设备端代码编译成 PTX (Intermediate Code) 或 SASS (GPU Machine Code)。 - 主机端编译:
nvcc此时会调用系统上配置的gcc或g++来编译主机端代码(main()函数) - 链接:
nvcc调用系统的链接器 (ld) 来链接所有部分:编译好的主机对象文件、封装好的设备代码,以及所需的 CUDA 库(例如静态链接的libcudart.a)。

依赖 libcudart 和 libcudadevrt
上边最后链接的时候,静态链接了两个看起来很相似的库 cudart 和 cudadevrt,我用高亮标注了一下。他们两个都是运行时库,区别在于:
cudartCUDA Runtime 负责 CPU 端的工作,所有在 CPU (主机) 上执行的 CUDA Runtime API 函数的实现代码(例如,cudaMalloc、cudaFree、cudaMemcpy的实现代码,以及 GPU Kernel 启动的封装逻辑)。cudadevrtCUDA Device Runtime 是 设备端(GPU Kernel 内部)的运行时库。它提供了一些需要从 GPU 内部调用 CUDA 运行时 的特殊功能实现。比如例子中在simpleKernel()在 GPU 上运行,其中使用的printf(),并不是普通的 C 语言printf,它是一个 CUDA 扩展,需要在 GPU 内部调用特殊的设备运行时功能来缓冲和发送输出数据。
两者都是静态链接,以确保 minimal_cuda 文件的高度自包含和可移植性。
依赖 libcuda.so
其实这个二进制文件,还依赖另外一个重要的库 libcuda.so,这个作用我们后边再介绍,现在就是看看依赖。
但是构建过程中没有,通过 ldd minimal_cuda 也看不到,因为 ldd 只能识别 静态声明 的依赖。而该库是在前面介绍的 libcudart.so在程序启动后,会程序性地调用 dlopen() ,去查找并加载的。
dlopen() 可以通过 strace 指令捕获到,我们看到这里实际路径是/lib64/libcuda.so.1,该文件链接到了 libcuda.so.525.105.17:

二、GPU和显卡
前面的例子中,我们反复提到了「在CPU上运行」、「在GPU上运行」、「GPU Kernel」等。
其实这里需要澄清一个概念,上边有些地方表述不严谨,用 GPU 代指了显卡。
GPU和显卡还是有差别的,尤其是独立显卡。
什么是独立显卡
独立显卡 可以被视为一个独立的计算机系统(协处理器)最为恰当,因为它具备了独立运行所需的全部硬件组件:
- GPU (Graphics Processing Unit):处理器,显卡的核心计算单元,负责执行大规模并行 Kernel 代码。
- 显存 (VRAM):独立、高速的存储系统,专门用于存储计算数据、模型权重和纹理等。不共享主机的系统内存 (RAM)。
- PCB/供电/散热:物理载体和支持系统,确保 GPU 及其内存的稳定运行。
独立显卡不仅仅是物理上的独立,更重要的是功能和软件上的相对独立和隔离:
- 功能独立: GPU 能够独立于 CPU,在自己的 VRAM 中完成数据加载、计算和存储。这使得 CPU 和 GPU 可以同时处理不同的任务,并且是异步执行。
- 软件隔离: GPU Kernel 运行在 GPU 硬件上,它不运行在 Linux 内核空间,不能发起系统调用。它只能通过 NVIDIA 驱动层 来接收任务和分配资源。这种隔离性是确保操作系统稳定性的关键。
因此,在我们讨论的 CUDA 架构中,所有涉及 内存分配 (cudaMalloc) 和 异步执行 的操作,都是基于这种 独立显卡 提供的完整计算子系统来实现的。
查看显卡信息
nvidia-smi (NVIDIA System Management Interface) 是 NVIDIA 驱动层提供的一个核心工具,用于监控和管理 GPU 硬件状态。
下边图中的展示的显卡信息包括:
-
Driver Version (驱动版本,525.105.17) 是 Linux 内核空间 中的驱动程序版本。
-
CUDA Version (支持的 CUDA 版本,12.0) 是该驱动能支持的 CUDA API 最高版本。
-
两块A10显卡(GPU 0/1),每块的信息包括:显存使用量(Memory-Usage)、风扇转速(Fan)、GPU 温度(Temp)、性能状态(Perf,P0 是最高性能,P8 是最低功耗状态)、功耗使用(Pwr, 8W/150W)、GPU 利用率(GPU-Util)
三、宿主机上运行原理
前面我们已经在 Linux 宿主机上的验证了 GPU 软件栈是完整的。这个所谓的「栈」实际有三个层组成,其实通过前面的介绍,也很容易分辨和理解:
-
应用层:包含我们编写的应用程序代码。在我们的
minimal.cu例子中,main()函数中的 Kernel 启动指令simpleKernel<<<1, 1>>>();就是应用层发出的命令。应用层并不直接与 GPU 硬件对话,而是通过调用下一层(运行层)提供的函数接口(API)来请求服务。 -
运行层:它主要由 CUDA 运行时库
libcudart.so构成。功能包括:- API 接口: 提供了一套高级、易用的 API 供应用层调用
- 上下文管理: 负责初始化 GPU 设备、管理 GPU 内存、以及将 Kernel 启动请求(例如
simpleKernel<<<1, 1>>>();)格式化,准备发送给驱动层。
-
驱动层:整个 GPU 软件栈的硬件核心控制器。它由两部分组成:
- 用户模式驱动库 (
libcuda.so): 这是驱动程序的接口部分,位于用户空间。运行时层的请求最终会传达到这里。 - 内核模式驱动: 这是真正的驱动核心,位于 Linux 内核空间。它拥有对 GPU 硬件(如 NVIDIA A10)的最高控制权,负责内存的物理分配、任务的低延迟调度以及与硬件的直接通信。
- 用户模式驱动库 (
兼容性要求
我们在前面的文章中介绍过 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 后缀):

容器内依赖的库
首先,检查驱动核心 (libcuda.so),镜像中有文件 /usr/lib64/libcuda.so.1,所以我们的程序可以加载到这个动态库。该文件是个软连接,软连接到 /usr/lib64/libcuda.so.525.105.17,竟然跟宿主机上的版本一模一样。
这不是巧合,恰恰是是 Linux 内核 VFS (Virtual File System) 的一个功能——绑定挂载 (Bind Mount)。它将宿主机上的一个文件(或目录)原封不动地映射到容器内的指定路径上。对于容器内的进程而言,它看起来就像一个完全正常的、位于本地文件系统上的文件。它的权限、大小、时间戳都与宿主机上的原始文件一模一样。
直接 ls 是看不出来的,可以使用 stat 指令查看(左边为容器内,右边为宿主机),两个输出中文件的 Inode 和 Device ID 完全一致,证明它们是同一个文件系统对象,从而确定是绑定挂载。

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

然后,检查运行时库 (libcudart.so),在容器内执行:
ls /usr/local/cuda/targets/x86_64-linux/lib/libcudart.so.12
这个文件是容器镜像自身安装的,通过 findmnt 也可以看出是容器内部的文件:

可见,libcudart.so 是来自 nvidia/cuda:*-runtime 基础镜像。
通过库我们也可以看出:驱动核心留在宿主机,容器只负责提供应用环境。
核心机制:容器“借用”GPU
容器访问 GPU 的能力完全依赖于 NVIDIA Container Toolkit,这是一个专门为 Docker/Podman 等容器运行时设计的扩展插件。
我们可以通过检查 Docker 的配置,来查看是否有该插件(图中的 nvidia 运行时配置):
隔离穿透:设备节点映射
宿主机内核驱动在 /dev 目录下创建了 GPU 对应的设备节点(如 /dev/nvidia0)。
当我们使用 docker run --gpus all 命令运行容器时,NVIDIA 运行时会自动识别这些设备节点,并将其映射到容器内部。容器内的进程可以直接通过 /dev/nvidia0 向 GPU 发送 I/O 请求。

为什么必须挂载库
为什么 libcuda.so 必须从宿主机以绑定挂载 (Bind Mount) 的方式,将这些宿主机上的库文件“注入”到容器的文件系统中。
在 NVIDIA 的驱动架构中,用户模式驱动库 (libcuda.so.XXX) 充当了应用程序(通过 CUDA Runtime)进入 内核模式驱动 的门户。
- 内核驱动 (
nvidia.ko): 负责硬件控制、任务调度。 - 用户库 (
libcuda.so.XXX): 负责将用户空间的 API 调用(如 Kernel 启动、显存分配请求)转化为内核驱动能理解的低级命令和数据结构。
由于两者需要以极高的效率和精度进行通信,用户库的版本必须与内核驱动的版本高度匹配。如果版本不匹配,用户库发出的命令可能与内核驱动期望的数据结构不符,轻则导致性能下降,重则直接导致 Kernel 崩溃或程序错误。
我们的例子中,宿主机正在运行 Driver Version: 525.105.17,它依赖的 用户模式驱动库 也必须是 libcuda.so.525.105.17。
因此 NVIDIA Container Toolkit 会在容器启动运行时:
- 查询宿主机内核中运行的是 525.105.17 版本的驱动。
- 在宿主机的文件系统上找到这个版本对应的用户库文件。
- 注入容器:使用 绑定挂载,将这个精确匹配的
libcuda.so.525.105.17文件(及其软链接)映射到容器内,供容器中的libcudart.so使用。
这样,容器内的程序才能通过一个版本匹配的、完整的软件栈,安全、稳定地访问 GPU 硬件。
而CUDA Runtime (libcudart.so)库有一定的兼容性容忍:应用程序链接的 Runtime 版本低于驱动支持的最高 CUDA 版本即可,因此无需绑定挂载。
五、最小化运行容器
尽管缩小容器镜像和容器使用 GPU 原理关系不大,还是借此机会介绍一下——这也是相当实用的工程技巧,原理也非常简单。
之前的模型服务,是一个流水线,使用同一个镜像进行构建和运行。每次服务拉取镜像有 7G 多,耗时五分钟多,这样效率会很低。
通过前面我们的实验,其实可以看到,Nvidia 提供的官方镜像有两个,一个是 devel 一个是 runtime。前者是用于构建,而后者是用于运行。可以看到这两个镜像大小差距非常大(超过一倍多差距):

devel 镜像用于构建,会比 runtime 镜像多出很多内容:
- 完整的 CUDA Toolkit C/C++ 编译器
nvcc - 主机编译器
gcc,g++等,用于编译主机端代码 - 头文件
.h文件,用于程序编译时引用 CUDA API - 静态库
.a文件,例如libcudart.a - 动态库
.so文件,除了程序运行所需的最低限度libcudart.so和核心依赖之外,还包括其他可选附加的运行时库,包括 CuDNN, NCCL 等
因此,我们用类似的思路,去改造我们的流水线,将构建的镜像进行简化作为运行镜像。
启动镜像缩小了一半,启动时间也减少了近一半。

总结
本文通过一个最小的 CUDA 程序,深入解析了在 Linux 宿主机和容器化环境中运行 GPU 计算的完整软件栈架构。 我们理解了:
- GPU 软件栈由应用层、运行层和驱动层三部分组成。
- 驱动层包含用户模式驱动和内核模式驱动,内核模式驱动必须运行在宿主机内核空间。
- 容器通过 NVIDIA Container Toolkit 实现对 GPU 资源的访问,关键机制包括设备节点映射和用户模式驱动的绑定挂载。
- 通过使用轻量级的运行时镜像,可以显著减少容器启动时间和资源占用。
希望对大家理解容器化 GPU 计算的原理有所帮助!
