CUDA以及NVCC编译流程

文章转载自:
http://chenrudan.github.io/

在安装CUDA的时候,会安装三个大的组件[1],分别是NVIDIA驱动、toolkit和samples。驱动用来控制gpu硬件,toolkit里面包括nvcc编译器、Nsight调试工具(支持Eclipse和VS,linux用cuda-gdb)、分析和调试工具和函数库。samples或者说SDK,里面包括很多样例程序包括查询设备、带宽测试等等。

1. Runtime API vs Driver API

在写cuda程序时,除了自己写的kernel函数,常常会调用cuda接口函数,最常见的就是全局内存分配函数cudaMalloc(),这里分配的内存暂且理解为gpu硬件参数上的显存。然而在某种情况下可能会看到另外一个函数cuMemAlloc(),这两个函数本质上完成的功能是一样的,都是在分配全局内存,但却属于两套接口,分别为Runtime API和Driver API。下图是cuda软件层的一些组件,实际上在cuda的软件层面,Runtime比Driver API更高级,封装的更好,在Runtime之上就是封装的更好的cuFFT等库。这两个库的函数都是能直接调用的,但Driver API相对于Runtime对底层硬件驱动的控制会更直接更方便,比如对context的控制[2],Driver API调用硬件速度实际上比Runtime也快不了多少。不过Driver API向后兼容支持老版本的,这点Runtime就做不到,7.0的版本代码可能在6.5上就跑不了。大部分的功能两组API都有对应的实现,一般基于Driver API的开头会是cu,而基于Runtime API的开头是cuda,但基于Driver API来写程序会比Runtime API要复杂,虽然功能上差别不大,但是使用Runtime API和更高级的库函数就已经足够了。

2. 常用函数库
NVIDIA针对cuda的使用开发了很多好用的库,包括实现c++ STL的thrust、实现gpu版本blas的cublas、实现快速傅里叶变换的cuFFT、实现稀疏矩阵运算操作的cuSparse以及实现深度学习网络加速的cuDNN等等。在操作这些库时有一个通用的规范,即调用者进行设备内存的分配与释放,内存分配好后将指针传递给这些库接口,就可以进行计算了。

关于thrust,它最基本的数据类型是两个向量容器,host_vetcor和device_vector,分别对应了内存分配在cpu内存和cpu内存,并且提供了非常多的函数模板,例如归约、归并、排序、二分查找等等。此外支持很多STL容器,例如下面的例子(代码来源[4])中即可以从c++容器中将数据复制给thrust的vector,也能将thrust的数据复制给c++ stl。

std::list<int> stl_list;
stl_list.push_back(10);
stl_list.push_back(20);
stl_list.push_back(30);
stl_list.push_back(40);


// 从c++ stl的list来初始化device_vector 
thrust::device_vector<int> D(stl_list.begin(), stl_list.end()); 

// 将device_vector的内容复制到stl的vector中 
std::vector<int> stl_vector(D.size()); 
thrust::copy(D.begin(), D.end(), stl_vector.begin());

而cublas的接口设计基本上跟blas库是一致的,也是有lever1~3的接口,不同点在cuda的库风格是在使用相关函数之前要创建一个句柄,这个句柄也要传递到相关函数中去,在程序完成之后要销毁对应的句柄。因此cublas库函数原型比blas的参数要多。句柄的设置是为了方便执行多gpu,即在通过调用cudaSetDevice()来启用不同的gpu设备,在每个设备上又可以初始化一个独立的句柄,那么就能同时在多个gpu上执行。虽然cuda允许不同的线程调用同一个句柄,但是最好还是不要这样做。

至于cudnn,它是专门针对深度学习实现的一个库,目前主要还是实现卷积神经网络,加速效果很好,现在的一些深度学习框架基本上都支持它。

3. NVCC编译流程

由于程序是要经过编译器编程成可执行的二进制文件,而cuda程序有两种代码,一种是运行在cpu上的host代码,一种是运行在gpu上的device代码,所以NVCC编译器要保证两部分代码能够编译成二进制文件在不同的机器上执行。nvcc涉及到的文件后缀及相关意义如下表[5]。

这里写图片描述

此外编译的常用选项还有–link,编译并链接所有的输入文件,–run编译链接文件后直接执行,-m指定32位还是64位机器。

有时还会见到–gpu-architecture/-arch和–gpu-code/-code,它们的目标是为了让gpu代码能够兼容多种架构的gpu,它们的取值范围是一样的包括compute_10/compute_11/…/compute_30/compute_35/sm_10/sm_11/…/sm_30/sm_35,这些取值范围的意义实际上是指gpu的计算能力,或者是SM的版本,一般表示的是1.0/2.0/3.5这样的,具体体现在之前讲过的kepler/fermi的不同架构,所以两者实际含义是一样的(这里看[5]上表达应该是两者等价,但是为什么要搞两个,统一叫计算能力不行吗…)。而这里两个编译选项都要选择计算能力/sm版本,这是因为nvcc最后生成的可执行文件可以同时存在多个版本(对应compute_10/…/compute_35等)的kernel函数,这多个版本就通过这两个编译选项确定。这两个编译选项使得中间会生成的ptx文本文件和cubin二进制文件,它们指定了最后生成的可执行文件中可以满足的版本要求,即通过-arch指定ptx将来可以生成怎么样的版本(可以看成针对一个虚拟的gpu),而-code参数是指当前就要生成的二进制的版本(可以想象成一个真实的GPU),当前和将来的意思是指,最后生成的可执行文件中一个部分是马上就能在gpu上执行,而如果gpu硬件版本不支持这个能够马上执行的部分,那么显卡驱动会重新根据ptx指定的版本再生成一个能够执行的可执行版本,来满足这个gpu的硬件需求。它们的版本信息会先嵌入fatbin文件中,再通过fatbin与host代码编译生成的中间结果链接成最后的目标文件,这是针对一个.cu源文件生成一个.o文件,再将不同.o链接成可执行文件,那么这个可执行文件中就包含了多个版本的信息。假如取–arch=compute_10 –code=sm_13,在最后的可执行文件中,就有一个可以直接执行1.3的版本,假如此时gpu计算能力只有1.0,那么驱动会再次编译生成1.0版本的可执行文件,这个新的可执行文件就能在计算能力只有1.0的机器上运行了,从而通过这样的方式可以兼容不同计算能力的gpu。需要注意的是code的版本要高于arch。

显然这里有一个问题,一般而言生成的c++可执行程序就直接执行了,不会再有编译的过程,但是cuda不一样,它有一个机制叫做just-in-time(JIT,运行时编译),为了满足两种执行cuda程序的方式。第一种就是直接执行cubin版本,第二种就是显卡驱动通过JIT在运行的时候根据ptx版本再次编译生成可执行文件。

NVCC实际上调用了很多工具来完成编译步骤。在编译一个.cu源文件时,当输入下面的指令后,执行程序就会将整个编译过程都打印出来。cuda的整个编译流程分成两个分支,分支1预处理device代码并进行编译生成cubin或者ptx,然后整合到二进制文件fatbin中,分支2预处理host代码,再和fatbin一起生成目标文件。

nvcc --cuda test.cu -keep --dryrun

针对打印出来的每个过程中生成的文件做一个简单的分析,test.cu初始化一个runtime的硬件查询对象cudaDeviceProp,然后打印共享内存的大小。这里打印出来的内容只截取了每条命令的部分,主要给出中间的生成文件。

//打印信息
gcc -D__CUDA_ARCH__=200 -E -x c++ ... -o "test.cpp1.ii" "test.cu"

上面这一步是device代码预处理,它将一些定义好的枚举变量(例如cudaError)、struct(例如cuda的数据类型float4)、静态内联函数、extern “c++”和extern的函数、还重新定义了std命名空间、函数模板等内容写在main函数之前。

cudafe ... --gen_c_file_name "test.cudafe1.c" --gen_device_file_name "test.cudafe1.gpu" ...

这一步test.cpp1.ii被cudafe切分成了c/c++ host代码和.gpu结尾的device代码,其中main函数还是在.c结尾的文件中。

gcc -D__CUDA_ARCH__=200 -E -x c ... -o "test.cpp2.i" "test.cudafe1.gpu"
cudafe ... --gen_c_file_name "test.cudafe2.c" ... --gen_device_file_name "test.cudafe2.gpu"
gcc -D__CUDA_ARCH__=200 -E -x c ... -o "test.cpp3.i" "test.cudafe2.gpu"
filehash -s "test.cpp3.i" > "test.hash"
gcc -E -x c++ ... -o "test.cpp4.ii" "test.cu"
cudafe++ ... --gen_c_file_name "test.cudafe1.cpp" --stub_file_name "test.cudafe1.stub.c"

上面这段生成的test.cpp4.ii是在对host代码进行预处理,前面几行内容直接看文件有点看不出来,希望以后能够把这段补充起来。

cicc  -arch compute_20 ... --orig_src_file_name "test.cu" "test.cpp3.i" -o "test.ptx"
----------------
test.ptx:
    .version 4.1
    .target sm_20
    .address_size 64
----------------
test.cpp1.ii
    ".../include/sm_11_atomic_functions.h"
    ...
    ".../include/sm_12_atomic_functions.h"
    ...
    ".../include/sm_35_atomic_functions.h"

test.ptx文件中只记录了三行编译信息,可以看出对应了上面提到指定ptx的版本,以后可以根据这个版本再进行编译。实际上在host c++代码即每一个test.cpp*文件中,都包含了所有版本的SM头文件,从而可以调用每一种版本的函数进行编译。

ptxas -arch=sm_20 -m64  "test.ptx" -o "test.sm_20.cubin"

这一步叫做PTX离线编译,主要的目的是为了将代码编译成一个确定的计算能力和SM版本,对应的版本信息保存在cubin中。

fatbinary --create="test.fatbin" ... "file=test.sm_20.cubin" ... "file=test.ptx" --embedded-fatbin="test.fatbin.c" --cuda

这一步叫PTX在线编译,是将cubin和ptx中的版本信息保存在fatbin中。这里针对一个.cu源文件,调用系统的gcc/g++将host代码和fatbin编译成对应的目标文件。最后用c++编译器将目标文件链接起来生成可执行文件。

4. 个人补充:
常见版本差异列举:

sm_20 (deprecated) | Basic features + Fermi support

sm_30 and sm_32 | + Kepler support + Unified memory programming

sm_35 | + Dynamic parallelism support sm_50, sm_52, and sm_53 + Maxwell support

NVCC编译中PTX code是为了增加版本兼容性,所以有种说法是PTX生成版本越低越好,Binary 生成版本越高越好。这样兼容的跨度就最大。但这个跨度也仅限于同一代架构中,这种兼容性是指,X.Y的code可以兼容X.Z的code,其中Z>=Y.
PTX大致流程如下:
这里写图片描述

Read more at: http://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/index.html#ixzz4hP0KVTbA
Follow us: @GPUComputing on Twitter | NVIDIA on Facebook

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页