上一节我们手写了第一个 CUDA Kernel,算是牛刀小试。但我们其实并不知道我们写的 Kernel 执行的效率和性能如何,这就涉及了性能调优。
既然是「调优」,我们首先需要知道 CUDA Kernel 目前的耗时分布以及执行的性能瓶颈在哪里,因此就需要借助性能分析工具帮助我们获取到详细的执行信息,它就是 Nvidia 提供的一个命令行分析工具:nvprof 。
上一节我们使用 nvcc ./vector_add.cu -o add 命令生成了可执行文件,只需要在执行命令前面加上 nvprof,即执行 nvprof ./add,将会在终端中打印如下信息:
==33356== Profiling application: ./add
==33356== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 92.23% 570.25ms 1 570.25ms 570.25ms 570.25ms add_kernel(float*, float*, float*, int) 4.79% 29.586ms 1 29.586ms 29.586ms 29.586ms [CUDA memcpy DtoH]
2.99% 18.459ms 2 9.2297ms 9.2245ms 9.2349ms [CUDA memcpy HtoD]
API calls: 56.06% 619.64ms 3 206.55ms 9.4402ms 600.73ms cudaMemcpy
43.58% 481.72ms 3 160.57ms 359.50us 481.00ms cudaMalloc
0.16% 1.7937ms 101 17.759us 239ns 933.68us cuDeviceGetAttribute
0.09% 1.0061ms 3 335.36us 278.68us 444.81us cudaFree
0.09% 956.79us 1 956.79us 956.79us 956.79us cuDeviceTotalMem
0.01% 132.25us 1 132.25us 132.25us 132.25us cuDeviceGetName
0.00% 50.300us 1 50.300us 50.300us 50.300us cudaLaunchKernel
0.00% 14.994us 1 14.994us 14.994us 14.994us cudaDeviceSynchronize
0.00% 10.974us 1 10.974us 10.974us 10.974us cuDeviceGetPCIBusId
0.00% 3.0460us 3 1.0150us 421ns 2.1590us cuDeviceGetCount
0.00% 1.7330us 2 866ns 328ns 1.4050us cuDeviceGet
0.00% 543ns 1 543ns 543ns 543ns cuDeviceGetUuid
nvprof 还有很多参数可以指定,这个我们稍后再学习。我们学习下如何看懂它给出的执行信息。
我们逐行分析上面的日志输出,其中第一行给出的是被分析的程序名 ./add,即是我们前面 nvcc 编译生成的可执行文件:
==8936== Profiling application: ./add
第二部分是执行可执行文件时,GPU 各个主要「行为」的耗时占比、具体时间、调用次数、平均/最小/最大耗时,接口行为名称:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 92.23% 570.25ms 1 570.25ms 570.25ms 570.25ms add_kernel(float*, float*, float*, int) 4.79% 29.586ms 1 29.586ms 29.586ms 29.586ms [CUDA memcpy DtoH]
2.99% 18.459ms 2 9.2297ms 9.2245ms 9.2349ms [CUDA memcpy HtoD]
可以看出我们写的 CUDA 程序在 GPU 上主要包括 3 个关键活动:
add_kernel:即执行 kernel 的时间,占比 92%,耗时 570.25 msx → cuda_x,y → cuda_y 的 2 次拷贝,占比 2.99%,耗时 18.459 mscuda_out → out 的 1 次拷贝,占比 4.79%,耗时 29.586ms第三个部分是 CUDA API 的具体调用开销,这个是从 API 层面来解读各个阶段的耗时:
Type Time(%) Time Calls Avg Min Max Name
API calls: 56.06% 619.64ms 3 206.55ms 9.4402ms 600.73ms cudaMemcpy
43.58% 481.72ms 3 160.57ms 359.50us 481.00ms cudaMalloc
0.16% 1.7937ms 101 17.759us 239ns 933.68us cuDeviceGetAttribute
0.09% 1.0061ms 3 335.36us 278.68us 444.81us cudaFree
0.09% 956.79us 1 956.79us 956.79us 956.79us cuDeviceTotalMem
0.01% 132.25us 1 132.25us 132.25us 132.25us cuDeviceGetName
0.00% 50.300us 1 50.300us 50.300us 50.300us cudaLaunchKernel
0.00% 14.994us 1 14.994us 14.994us 14.994us cudaDeviceSynchronize
0.00% 10.974us 1 10.974us 10.974us 10.974us cuDeviceGetPCIBusId
0.00% 3.0460us 3 1.0150us 421ns 2.1590us cuDeviceGetCount
0.00% 1.7330us 2 866ns 328ns 1.4050us cuDeviceGet
0.00% 543ns 1 543ns 543ns 543ns cuDeviceGetUuid
其中最耗时的就是 3 次 cudaMemcpy 和cudasMalloc 的调用,99% 的时间都在干这两个事情,可以看出显存分配是一个比较「重」的操作,任何时候我们都应该尽量避免频繁的显存分配操作。在深度学习框架中,常会借助「内存池」技术一次申请较大的显存块,然后自己管理切分、分配和回收,这样就可以减少向系统 cudaMalloc 的次数,感兴趣的同学可以参考Paddle 源码之内存管理技术。
剩下的 API 调用的开销基本差别不是特别大,大多数都是在 us 级别,我们一一介绍各个 API 的作用: