TL;DR
现有的参考资料 blog_1blog_2blog_3 非常详细地记录了博主们分析 nvcc 的 CUDA 程序构建过程,但是我发现在我的平台上的编译过程和他们有些许出入,遂进行记录,我的平台参数如
| 部件 | 参数/版本 |
|---|---|
| GPU | NVIDIA GeForce RTX 4060 Laptop GPU |
| CUDA Toolkit | 12.2 |
| NVIDIA Driver | 536.45 |
nvcc |
V12.1.105 |
构建过程简介和程序准备
CUDA 程序构建与 nvcc
CUDA 程序的编译与链接
CUDA 程序的构建过程可以分为两个阶段:
在 .cu 文件,一共可以分为 4 步处理: .cu 文件进行 Host 侧的预处理,包括展开头文件内容,替换 <<<>>> 语法糖等;.cu 文件,进行 Device 侧的预处理,然后首先编译出 .ptx 文件,如果 .cu 文件中带有 Kernel 的相关定义,则在 .ptx 文件中就会出现对应的 PTX 程序;.fatbin 文件中,后者实际上是一段 C 程序代码,其中用 C 结构体封装了已经被编译为二进制的程序;gcc -c 指令),将 Host 侧程序和已经被编译为 Device 二进制的程序一起,打包为一个待重定位文件 (i.e., .o 文件)。在 nvcc 的官方文档 nvcc_doc 中将上述编译流程定义为
在
NVCC
nvcc 在 NVIDIA 官方文档 nvcc_doc 中被定义为 CUDA Compiler Driver,其目的在于掩盖包含了多次程序分离、预处理、编译和合并的 nvcc 并不是一个编译器,它的功能可以视为根据用户传入的命令行参数,按照一定的顺序调用对应的编译器/链接器对程序进行构建的 wrapper,因此才被称为编译器驱动。
就后文的观察来看,nvcc 调用的编译器包括:
gcc: Host 侧程序的编译器,使用-E指令进行预处理;使用-c指令编译出待重定位文件;cicc: Device 侧 PTX 程序的编译器,其是一个 LLVM-based Compiler cicc_zhihu,负责将 C/C++ 定义的 device function 转化为虚拟架构 PTX 程序;cudafe++: 用于处理.cu文件中的 CUDA 语法糖 (e.g.,<<<>>>调用符号,__global__kernel 定义等)
理解 nvcc 的编译参数
接着我们理解一下 nvcc 的编译参数 nvcc_param_tips。
1 | nvcc main.cu gemm_kernel_1.cu gemm_kernel_2.cu help_function.cu \ |
就最常用的参数来说,arch 用于指定虚拟架构,code 用于指定 nvcc 最后将向编译产物 (i.e., cubin 文件) 中放入哪些程序。例如 nvcc 将以 compute_89 的 PTX 代码,编译出 sm_89 和 sm_90 的 SASS 代码,然后最终向 cubin 中放入 compute_89 的 PTX,以及 sm_89 和 sm_90 的 SASS 代码。
1 | nvcc main.cu gemm_kernel_1.cu gemm_kernel_2.cu help_function.cu \ |
有时候我们又希望编译产物中可以包含基于多种虚拟架构的 PTX 代码,以及他们编译出来的对应 SASS,此时我们可以使用 gencode 选项。如 compute_89 编译形成的 sm_89 SASS 程序;compute_89 编译形成的 sm_90 SASS 程序;compute_90 编译形成的 sm_90 SASS 程序;compute_89 PTX 程序和 compute_90 PTX 程序。
-rdc=true 参数用于使能 Relocatable Device Code,使得在编译的时候不需要将 __device__ __global__ nvidia_rdc
程序准备
我们进行编译实验的 CUDA 程序结构如 gemm_kernel_1.cu 和 gemm_kernel_2.cu 中分别定义了 1 个 kernel,其中 gemm_kernel_2.cu 中定义的 kernel 调用了在 help_function.cu 中定义的 device function。另外我们还在 gemm_kernel_cublas.cu 中定义了一个函数,其使用了来自 cuBLAS 库 cublas_doc 的 APIs。程序的主线逻辑则位于 main.cu 中。
1 | . |
分析这 5 个文件的编译过程,对应 5 个验证目的:
main.cu: launch kernel 的<<<>>>语法糖在编译过程中被转化为了什么?gemm_kernel_1.cu: kernel 是怎么被编译的?kernel 是怎么被注册和调用的?gemm_kernel_2.cu: 调用了位于其它 .cu 文件定义的 device funtion 的 kernel 是怎么被编译的?以及注册和调用的?gemm_kernel_cublas.cu: 调用了第三方库 (e.g., cuBLAS cublas_doc) 的 .cu 文件是怎么被编译和链接的?help_function.cu: device function 是怎么被编译的?
就具体文件内容来说,我们首先在 help_function.cu 中定义了一个 device function get_flat_index,如
1 |
|
在 gemm_kernel_1.cu 和 gemm_kernel_2.cu 中,我们分别定义了一个用于 GEMM 计算的 kernel,程序分别如 gemm_kernel_2.cu 中定义的 kernel gemm_kernel_2 使用了 device function get_flat_function,而 gemm_kernel_1.cu 中定义的 kernel gemm_kernel_1 没有使用。
1 |
|
1 |
|
在 gemm_kernel_cublas.cu 中,我们则定义了一个函数,其调用了 cuBLAS 库的部分 APIs,具体文件内容如下:
1 |
|
我们把以上所有函数的函数声明放在了 gemm_kernel.cuh 中:
1 |
|
在 main.cu 中,我们则完成了一个简单的 CUDA 程序的定义:
1 |
|
编译过程 Overview
为了探究过程完整性,我们运行如 nvcc 暴露其编译过程:
1 | nvcc main.cu gemm_kernel_1.cu gemm_kernel_2.cu -arch=compute_89 -code=compute_89,sm_89,sm_90 --verbose -o gemm_exe |
运行过后,我们可以获得如
1 | $ _NVVM_BRANCH_=nvvm |
下面我们按照上面的日志输出内容,逐命令地对编译过程进行分析。
tmp 改为了本地临时文件夹 ./tmp,方便我们进行文件增删的分析。
main.cu 的编译
首先我们对 main.cu 代码的编译过程进行拆解。
预处理 Host 侧源代码
1 | gcc -D__CUDA_ARCH_LIST__=890 \ |
运行了上述命令后,文件夹结构如下所示:
1
2
3
4
5
6
7 .
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
└── tmpxft_0000047f_00000000-5_main.cpp4.ii
上述命令实际上是 gcc -E 指令,目的是对 main.cu 进行 main.cu 中进行定义和展开。同时上述命令还在生成的文件中添加了若干宏定义,生成的文件 main.cpp4.ii 是一个长达 3 万行的代码文件,截取其中内容,如下所示:
1 | // ... |
在上述代码中,我们在 Line 24 和 25 处可以看见 gemm_kernel.cuh 的函数声明被展开到了 main.cpp4.ii 中,并且 __global__ 的前缀被更换为了 __attribute__((global));另外我们在 Line 126 和 Line 129 处仍然可以看见程序使用 <<<>>> 的方式来异步地调用 kernel,可见此时程序只是进行了预处理工作,CUDA device 侧程序和 CUDA Runtime 的程序用法,依然和 Host 侧代码没有分离。
分离 Host 侧源代码
1 | cudafe++ --c++17 \ |
命令运行后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9 .
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
├── tmpxft_0000047f_00000000-4_main.module_id
# ├── tmpxft_0000047f_00000000-5_main.cpp4.ii
└── tmpxft_0000047f_00000000-6_main.cudafe1.cpp
可见运行后,将会生成两个新文件: main.cudafe1.cpp 和 main.module_id 文件。其中 main.cudafe1.cpp 的文件内容摘抄如下:
1 | # 4 "gemm_kernel.cuh" |
在 Line 2 和 Line 4 中可以看见,kernel 的定函数声明中 __attribute__((global)) 的编译器注释已经被去掉了,实际上这里留下的是同名的函数接口声明 void gemm_kernel_x(const int*, const int*, int*, const int, const int, const int) (以下简称为 gemm_kernel_x 接口);另外,在 Line 110 和 Line 114 中可以看见,kernel launch 的方式已经被改为了先调用 __cudaPushCallConfiguration 将 Launch 参数 (e.g., gridDim, blockDim, etc.) push 到某处,然后直接运行 gemm_kernel_x 接口。综上,此时的 Host 侧代码已经和 Device 侧代码分离开来了,main.cudafe1.cpp 中包括的是 Host 侧的代码。
值得注意的是,在 Line 126~133 还可以看见 main.cudafe1.cpp include 了 main.cudafe1.stub.c 文件,后者在此时还没有被生成,实际上后者将间接包含编译后的 Device 侧代码,我们在下文将会看到它的内容。
另外,main.module_id 文件内容如下所示,该文件存储了当前正在编译的 CUDA module 的 id 信息,我们在后文把它称为 module_id。
1 | _9df44bf1_7_main_cu_main |
预处理 Device 侧源代码
1 | gcc -D__CUDA_ARCH__=890 \ |
命令运行后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9
10 .
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
├── tmpxft_0000047f_00000000-17_main.cpp1.ii
# ├── tmpxft_0000047f_00000000-4_main.module_id
# ├── tmpxft_0000047f_00000000-5_main.cpp4.ii
# └── tmpxft_0000047f_00000000-6_main.cudafe1.cpp
实际上 main.cu 进行预处理。这里的预处理唯一的不同可能就是多加了宏定义 __CUDA_ARCH__,该命令生成的文件是 main.cpp1.ii,由于内容和 main.cpp4.ii 并无明显出入,此处不再赘述。
编译出 PTX 代码
1 | cicc --c++17 \ |
cicc 大概率不在系统 binary 搜索路径下,需要手动向 terminal 配置文件中添加:export PATH=/usr/local/cuda-12.1/nvvm/bin:$PATH
运行了上述命令后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 .
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
# ├── tmpxft_0000047f_00000000-17_main.cpp1.ii
# ├── tmpxft_0000047f_00000000-4_main.module_id
# ├── tmpxft_0000047f_00000000-5_main.cpp4.ii
├── tmpxft_0000047f_00000000-6_main.cudafe1.c
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.cpp
├── tmpxft_0000047f_00000000-6_main.cudafe1.gpu
├── tmpxft_0000047f_00000000-6_main.cudafe1.stub.c
└── tmpxft_0000047f_00000000-6_main.ptx
可见一共生成了 4 个新文件,其中较为重要的 2 个文件是 main.ptx 和 cudafe1.stub.c。
1 |
|
cudafe1.stub.c 文件的内容如 __attribute__((__constructor__)) 的函数 __sti____cudaRegisterAll,这意味着它会在程序启动时被执行,它的定义位于 Line 16-18,可以看见它调用了 __cudaRegisterBinary,顾名思义是在注册跑在 device 上的二进制程序。__cudaRegisterBinary 是一个 CUDA Runtime 的内部 API,我们可以看到它的逻辑是传入注册回调函数 __nv_cudaEntityRegisterCallback,由于在 main.cu 中我们并没有定义任何 kernels,因此我们在这个用于注册的回调函数中并不能看到过多细节,在 compile_gemm_kernel_1 中我们将看到 1 个 kernel 被注册进 runtime 的更多细节。
而对于 main.ptx,由于我们在 main.cu 中没有定义任何 kernel,所以在
1 | // |
编译出 SASS 代码 (cubin)
1 | ptxas -arch=sm_90 -m64 "./tmp/tmpxft_0000047f_00000000-6_main.ptx" -o "./tmp/tmpxft_0000047f_00000000-18_main.sm_90.cubin" |
运行了上述命令后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 .
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
# ├── tmpxft_0000047f_00000000-17_main.cpp1.ii
├── tmpxft_0000047f_00000000-18_main.sm_90.cubin
├── tmpxft_0000047f_00000000-19_main.sm_89.cubin
# ├── tmpxft_0000047f_00000000-4_main.module_id
# ├── tmpxft_0000047f_00000000-5_main.cpp4.ii
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.c
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.cpp
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.gpu
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.stub.c
# └── tmpxft_0000047f_00000000-6_main.ptx
新生成的 main.sm_90.cubin 和 main.sm_89.cubin 理论上应包含在设备上被执行的 SASS 二进制。同理,由于我们在 main.cu 并没有定义任何 kernels,因此这两个 cubin 文件中不会包含任何内容,如
1 | $ objdump -s ./tmp/tmpxft_0000047f_00000000-18_main.sm_90.cubin |
1 | $ objdump -s ./tmp/tmpxft_0000047f_00000000-19_main.sm_89.cubin |
合并 PTX 和 SASS 代码 (合并 ptx 和 cubin 生成 fatbin)
1 | fatbinary -64 \ |
运行了上述命令后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 .
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
# ├── tmpxft_0000047f_00000000-17_main.cpp1.ii
# ├── tmpxft_0000047f_00000000-18_main.sm_90.cubin
# ├── tmpxft_0000047f_00000000-19_main.sm_89.cubin
├── tmpxft_0000047f_00000000-3_main.fatbin.c
# ├── tmpxft_0000047f_00000000-4_main.module_id
# ├── tmpxft_0000047f_00000000-5_main.cpp4.ii
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.c
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.cpp
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.gpu
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.stub.c
# └── tmpxft_0000047f_00000000-6_main.ptx
上述命令将 main.sm_89.cubin,main.sm_90.cubin 和 main.ptx 合并到了 main.fatbin.c 中,后者的文件内容如下所示:
1 |
|
Line 73 定义的类型为 __fatBinC_Wrapper_t 的变量 __fatDeviceText 即是我们在 main.fatbin.c 的最终产物,可以观察到它最终被包括在了名为 nvFatbinSegment 的 section 中。__fatBinC_Wrapper_t 的结构体定义没有明确的文档说明,但可以参考 Yifan Sun stackoverflow_key 给出的逆向工程分析:
1 | struct { |
其首先包含一个 magic number 0x466243b1;然后是当前 cubin 的序列号,当前是我们编译的第一个 cubin,因此其序列号为 1;然后是指向真正 cubin 的指针,在 .nv_fatbin section 中;最后是一个指向 data segment 的指针,
生成目标文件
1 | gcc -D__CUDA_ARCH__=890 \ |
运行了上述命令后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18.
# ├── gemm_kernel.cuh
# ├── gemm_kernel_1.cu
# ├── gemm_kernel_2.cu
# ├── main.cu
└── tmp
# ├── tmpxft_0000047f_00000000-17_main.cpp1.ii
# ├── tmpxft_0000047f_00000000-18_main.sm_90.cubin
# ├── tmpxft_0000047f_00000000-19_main.sm_89.cubin
├── tmpxft_0000047f_00000000-20_main.o
# ├── tmpxft_0000047f_00000000-3_main.fatbin.c
# ├── tmpxft_0000047f_00000000-4_main.module_id
# ├── tmpxft_0000047f_00000000-5_main.cpp4.ii
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.c
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.cpp
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.gpu
# ├── tmpxft_0000047f_00000000-6_main.cudafe1.stub.c
# └── tmpxft_0000047f_00000000-6_main.ptx
上述命令是一个 gcc -c 指令,代表着该指令完成了编译,但是还没有进行链接。其完成了对 main.cudafe1.cpp 文件的编译过程(生成自 1
objdump -s ./tmp/tmpxft_0000356e_00000000-11_main.o
通过运行以上命令,我们可以看到编译得到的目标文件中的 section 分布情况,如
1 | ./tmp/tmpxft_0000047f_00000000-20_main.o: file format elf64-x86-64 |
至此,我们完成了从 .cu 文件到 .o 文件的分析过程,但是由于我们在 main.cu 并没有定义 kernel,因此有一些细节我们实际上没能观察到,compile_gemm_kernel_1 中我们将以 gemm_kernel_1.cu 的编译过程为例,展示其与 main.cu 编译过程的区别。
gemm_kernel_1.cu 的编译
由于 gemm_kernel_1.cu 和 gemm_kernel_2.cu 两个文件的编译过程是完全一样的,所以以下只以 gemm_kernel_1.cu 为例。
cudafe1.stub.c 文件的内容如 void sum(int *, int *, int *) 接口的进一步定义,其实际上调用了在同文件下定义的 __device_stub__Z3sumPiS_S_ 函数,Line 17~25 展示了后者的实现细节,其使用了 __cudaLaunchPrologue、__cudaSetupArgSimple 和 __cudaLaunch 三个宏,这三个宏的定义如下所示:
1 |
|
因此,void sum(int *, int *, int *) 接口的行为实际上是,先使用 __cudaLaunchPrologue 宏,初始化出一个长度与 kernel 参数列表长度相同的数组,然后使用 __cudaSetupArgSimple 宏向数组中填充参数,最后调用 __cudaLaunch 宏启动 kernel。__cudaLaunch 中则实际上显示调用 __cudaPopCallConfiguration,将我们在 __cudaPushCallConfiguration 压入 CUDA Runtime 中维护的某个 stack 的 kernel 调用参数 (i.e., gridDim, blockDim, sharedMem, stream) 重新弹出来,最后再调用 cudaLaunchKernel 启动对应的 kernel。
最后在 __attribute__((__constructor__)) 的函数 __sti____cudaRegisterAll,也就是说它在程序启动时就会被自动执行。这个函数的定义可以在 Line 46~48 中找到,可以看到其实际上调用了 __cudaRegisterBinary,根据名字推测,其用于向 device 注册 cuda 二进制,其实际上调用了 __cudaRegisterEntry (Line 38) 完成了对 sum 这个 kernel 的注册工作。
这里有一个有趣的地方:上面我们看到的 cudaLaunchKernel 和 __cudaRegisterEntry 接口,它都接受了 void sum(int *, int *, int *) 接口的函数地址作为参数。细心的读者可能会有疑问:void sum(int *, int *, int *) 接口不是给 Host 侧 C++ 代码使用的么?为什么注册和发射 kernel 也用它来作为参数?实际上此时 void sum(int *, int *, int *) 的函数指针是被 CUDA Runtime 当作 key 来使用的 stackoverflow_key,在 Line 38~43 我们可以看见 __cudaRegisterEntry 的参数列表中有两个重要参数: 一个是 void sum(int *, int *, int *) 的函数指针,另一个名为 _Z3sumPiS_S_ 的名称我们可以在 __cudaRegisterEntry 这里相当于在内部维护了一个映射:当 cudaLaunchKernel 尝试使用 void sum(int *, int *, int *) 的函数指针发射 kernel 时,Runtime 就知道用户想要运行的是 _Z3sumPiS_S_ 对应的程序了。
合并多个 .cu 文件
链接
1 | nvlink -m64 --arch=sm_52 \ |
运行了上述命令后,文件夹结构如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16.
# ├── main.cu
└── tmp
# ├── tmpxft_0000356e_00000000-10_main.sm_52.cubin
# ├── tmpxft_0000356e_00000000-11_main.o
├── tmpxft_0000356e_00000000-12_staticthread_dlink.sm_52.cubin
# ├── tmpxft_0000356e_00000000-3_main.fatbin.c
# ├── tmpxft_0000356e_00000000-4_main.module_id
# ├── tmpxft_0000356e_00000000-5_main.cpp4.ii
# ├── tmpxft_0000356e_00000000-6_main.cudafe1.c
# ├── tmpxft_0000356e_00000000-6_main.cudafe1.cpp
# ├── tmpxft_0000356e_00000000-6_main.cudafe1.gpu
# ├── tmpxft_0000356e_00000000-6_main.cudafe1.stub.c
# ├── tmpxft_0000356e_00000000-6_main.ptx
├── tmpxft_0000356e_00000000-7_staticthread_dlink.reg.c
# └── tmpxft_0000356e_00000000-9_main.cpp1.ii
新增加的文件中,staticthread_dlink.sm_52.cubin 包含了 nvlink 输入的所有目标文件中 Device 侧程序 (i.e., PTX 和 SASS) 的集合,我们此处只有一个目标文件,因此 staticthread_dlink.sm_52.cubin 只包含了前面我们处理过的 main.cu 中包含的 Device 侧程序;staticthread_dlink.reg.c
1