cuda开发进阶

cuda开发进阶

1 线程组织

 CUDA线程分成Grid和Block两个层次:

image

 一个单独的kernel启动的所有线程组成一个grid,grid中所有线程共享global memory。一个grid由许多block组成,block由许多线程组成。grid和block都可以是一维、二维或者三维,上图是一个二维的grid和二维的block。相关CUDA内置变量如下:

  • blockIdx:block索引,blockIdx.x表示block的x坐标
  • threadIdx:线程索引,同理blockIdx
  • blockDim:block维度,上图中blockDim.x=5
  • gridDim:grid维度,同理blockDim

一般会把grid组织成2D,block为3D。grid和block都使用dim3作为声明:

dim3 block{3};
dim3 grid{(nElem+block.x-1)/block.x}

dim3仅为host端可见,其对应的device端类型为uint3。

 2 kernel启动

 CUDA kernel调用格式为:

kernel_name<<<grid,block>>>(arument list);

 其中grid和block即为前文介绍的类型dim3的变量。通过两个变量可以配置一个kernel的线程的总和,以及线程的组织形式。例如:

kernel_name<<<4,8>>>(argument list);

该行代码表明grid为一维,有个block,block为一维,每个block有8个线程,故此共有4*8=32个线程。

image

 所有CUDA kernel的启动都是异步的,当CUDA kernel被调用时,控制权会立即返回给CPU。

函数类型标识符:

image

 __device__和__host__可以组合使用。

kernel的限制:

  • 仅能获取device memeory
  • 必须返回void类型
  • 不支持可变数目参数
  • 不支持静态变量
  • 不支持函数指针
  • 异步

 3 线程索引

 线程的组织形式对程序的性能影响至关重要。矩阵在memory中是row-major线性存储的:

image

 在kernel中,线程的唯一索引非常有用,为例确定线程的索引,我们以2D为例:

  • 线程和block索引
  • 矩阵中元素的坐标
  • 线性global memory的偏移

首先,可以将thread和block索引映射到矩阵坐标:

ix=threadIdx.x+blockIdx.x*blockDim.x
iy=threadIdx.y+blockIdx.y*blockDim.y

 之后可以利用上述变量计算线性地址:

idx=iy*nx+ix

image

 上图展示了block和thread索引,矩阵坐标以及线性地址之间的关系。相邻thread拥有连续的threadIdx.x,也就是索引为(0,0),(1,0),(2,0),(3,0)...的thread连续,而不是(0,0),(0,1),(0,2),(0,3)...连续,这跟线性代数中的矩阵不一样。

我们来验证下面的关系:

thread_id(2,1)block_id(1,0) coordinate(6,1global index 14 ival 14

如下图:

image

 4 device管理

掌握GPU信息查询信息很重要,这可以帮助设置kernel的执行配置。runtime api查询GPU信息:

cudaError_t cudaGetDeviceProperties(cudaDeviceProp *prop, int device);

如何决定最佳GPU?对于支持多GPU系统,需要从中选择一个来作为我们的device,选择出最佳计算性能GPU的一种方法就是由其拥有的处理器数量决定,可以使用下面的代码来选择最佳GPU。

int numDevices = 0;
cudaGetDeviceCount(&numDevices);
if (numDevices > 1) {
    int maxMultiprocessors = 0, maxDevice = 0;
    for (int device=0; device<numDevices; device++) {
        cudaDeviceProp props;
        cudaGetDeviceProperties(&props, device);
        if (maxMultiprocessors < props.multiProcessorCount) {
            maxMultiprocessors = props.multiProcessorCount;
            maxDevice = device;
        }
    }
    cudaSetDevice(maxDevice);
}

设置device:对于多GPU系统,使用nvidia-smi可以查看各GPU属性,每个GPU从0开始依次标注,使用环境变量CUDA_VISIBLE_DEVICES可以指定GPU而不用修改应用程序。可以设置环境变量CUDA_VISIBLE_DEVICES-2来屏蔽其他GPU,这样只有GPU2能被使用。当然也可以使用CUDA_VISIBLE_DEVICES-2,3来设置多个GPU,他们的device ID分别为0和1。

 5 GPU架构

SM(Streaming Multiprocessor)是GPU架构中非常重要的部分,GPU硬件的并行性就是用SM决定的。以Fermi架构为例,其包含以下主要组成部分:

  • CUDA cores
  • Shared Memory/L1 Cache
  • Register File
  • Load/Store Units
  • Special Function Units
  • Warp Scheduler

GPU中,每个SM都设计成支持数以百计的线程并行执行,并且每个GPU都包含了很多的SM。当一个kernel启动后,thread会被分配到这些SM中执行。大量的thread可能会被分配到不同的SM,但是同一个block中的thread必须在同一个SM中并行执行。

CUDA采用Single Instruction Mutiple Thread(SIMT)的架构来管理和执行thread,这些thread以32个为单位组成一个单元,称为warps。warp中所有的线程并行的执行相同的指令。每个thread拥有自己的instruction address counter和状态寄存器,并且用该线程自己的数据执行指令。

SIMT和SIMD(Single Instruction Mutiple Data)类似,SIMT应该算是SIMD的升级版,更灵活,但效率略低,SIMT是NVIDIA提出的GPU新概念。二者都通过将同样的指令广播给多个执行单元来实现并行。一个主要的不同就是,SIMD要求所有的vector element在一个统一的同步组里同步执行,而SIMT运行线程们在一个warp中独立执行。SIMT有三个SIMD没有的主要特征:

  • 每个thread拥有自己的instruction address counter
  • 每个thread拥有自己的状态寄存器
  • 每个thread可以有自己独立的执行路径

一个block只会由一个SM调度,block一旦被分配好SM,该block就会一直驻留在该SM中,直到执行结束。一个SM可以同时拥有多个block。下图显示了软硬件方面的术语:

image

需要注意的是,大部分thread只是逻辑上并行,并不是所有的thread可以在物理上同时进行。这会导致同一个block中的线程可能会有不同步调。并行thread之间的共享数据会导致竞态;多线程请求同一个数据会导致未定义行为。CUDA提供了API来同步一个block的thread以保证在进行下一步处理前,所有thread都到达某个时间点。不过,我们是没有什么原子操作来保证block之间同步的。

同一个warp中的thread可以任意顺序执行,active warps被SM资源限制。当一个warp空闲时,SM可以调度驻留在该SM中一个可用warp。在并非的warp之间切换是没有什么消耗的,因为硬件资源早就被分配到所有的thread和block,所有该新调度的warp的状态已经存储在SM中了。

SM可以看作GPU的心脏,寄存器和共享内存是SM的稀缺资源。CUDA将这些资源分配给所有驻留在SM中的thread。因此,这些有限的资源就使每个SM中的active warps由非常严格的限制,也就限制了并行能力。所有,掌握部分硬件知识,有助于CUDA性能提升。

5.1 Fermi架构

 Fermi是第一个完整的GPU计算架构:

  • 512个accelerator cores 即所谓CUDA cores (包含ALU和FPU)
  • 16个SM,每个SM包含32个CUDA core
  • 6个384位GDDR5 DRAM,支持6GB global on-board memory
  • GigaThread engine将thread blocks分配给SM调度
  • 768KB L2 cache
  • 每个SM有16个load/store单元,允许每个clock cycle为16个thread(即所谓half-warp,不过现在不提这个概念)计算源地址和目的地址
  • Special function units(SFU)用来执行sin cosine等
  • 每个SM两个warp scheduler,两个instruction dispatch unit,当一个block被分配到一个SM中后,所有该block中的thread会被分到不同的warp中
  • Fermi (compute capability 2.x) 每个SM同时可以处理48个warp共计1536个thread。

image

 每个SM由以下几个部分组成:

  • 执行单元(CUDA cores)
  • 调度分配warp的单元
  • shared memory,register file,L1 cache

5.2 Kepler架构

 Kepler相较于Fermi更快,效率更高,性能更好。

  •  15个SM
  • 6个64位memory controller
  • 192个单精度CUDA cores,64个双精度单元,32个SFU,32个load/store单元(LD/ST)
  • 增加register file到64K
  • 每个Kepler的SM包含四个warp scheduler,8个instruction dispatches,使得每个SM可以同时issue和执行4个warp
  • Kepler K20x (compute capability 3.5) 每个SM可以同时调度64个warp攻击2048个thread

image

 image

5.2.1 Dynamic Parallelism

Dynamic Parallelism是Kepler的新特性,运行GPU动态启动新的Grid。有了这个特性,任何kernel内都可以启动其它的kernel了。这样直接实现了Kernel的递归已经解决了kernel之间数据依赖问题。

image

5.2.2 Hyper-Q

Hyper-Q是Kepler的另一个新特征,增加了CPU和GPU之间硬件上的联系,使CPU可以在GPU上同时运行更多任务。这样就可以增加GPU的利用率减少CPU的闲置时间。Fermi依赖一个单独的硬件上的工作队列来从CPU传递任务给GPu,这样在某个任务阻塞时,会导致之后的任务无法得到处理,Hyper-Q解决了这个问题。相应的,Kepler为GPU和CPU提供了32个工作队列。

image

 6 warp

 逻辑上,所有thread是并行的,但是,从硬件的角度来说,实际上并不是所有的thread能够同一时刻执行,接下来我们来解释有关warp的一些本质。

6.1 warps 与 blocks

 warp是SM的基本执行单元。一个warp包含32个并行的thread,这32个thread执行SIMT模式。也就是说,所有thread执行同一条指令,并且每个thread会使用各自的data执行该指令。

block可以是一维、二维或者三维的,但是从硬件角度看,所有thread都被组织成一维,每个thread都有唯一的ID。

每个block的warp数量可以由下面公式计算获得:

image

 

 一个warp中的线程必然在同一个block中,如果block所含线程数目不是warp大小的整数倍,那么多出的哪些thread所在warp中,会剩余一些inactive的thread,也就是说,解释凑不够整数倍的thread,硬件也会为warp凑足,只不过哪些thread是inactive状态,需要注意的是,即使这部分thread是inactive,也会消耗SM资源。

image

 

 6.2 warp divergence

控制流语句普遍存在各种编程中,GPU支持传统的,C-style,显示控制流结构,例如if..else,for,while等等。CPU有复杂的硬件设计可以很好的做分支预测,即预测应用程序走哪个path。如果预测正确,那么CPU只会有很小的消耗。和CPU对比来说,GPU没有那么复杂的分支预测(CPU和GPU差异引起的)。那么,因为所有同一个warp中的thread必须执行相同的指令,如果这些线程遇到控制流语句是,如果进入不同分支,那么同一时刻除了正在执行的分支之外,其余分支都被阻塞了,十分影响性能。这类问题就是warp divergence。

注意,warp divergence问题只会发生在同一个warp中。下图展示了warp divergence问题:

image

 为了获得最好的性能,就需要避免同一个warp存在不同的执行路径。避免该问题的方法很多,比如这样一个情形,假设有两个分支,分支的决定条件是thread的唯一ID的奇偶性:

__global__ void mathKernel1(float *c) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    float a, b;
    a = b = 0.0f;
    if (tid % 2 == 0) {
        a = 100.0f;
    } else {
        b = 200.0f;
    }
    c[tid] = a + b;
}

一种方法是,将条件改为以warp大小为步调,然后取奇偶,如下:

__global__ void mathKernel2(void) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    float a, b;
    a = b = 0.0f;
    if ((tid / warpSize) % 2 == 0) {
        a = 100.0f;
    } else {
        b = 200.0f;
    }
    c[tid] = a + b;
}

代码:

int main(int argc, char **argv) {
// set up device
int dev = 0;
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, dev);
printf("%s using Device %d: %s\n", argv[0],dev, deviceProp.name);
// set up data size
int size = 64;
int blocksize = 64;
if(argc > 1) blocksize = atoi(argv[1]);
if(argc > 2) size = atoi(argv[2]);
printf("Data size %d ", size);
// set up execution configuration
dim3 block (blocksize,1);
dim3 grid ((size+block.x-1)/block.x,1);
printf("Execution Configure (block %d grid %d)\n",block.x, grid.x);
// allocate gpu memory
float *d_C;
size_t nBytes = size * sizeof(float);
cudaMalloc((float**)&d_C, nBytes);
// run a warmup kernel to remove overhead
size_t iStart,iElaps;
cudaDeviceSynchronize();
iStart = seconds();
warmingup<<<grid, block>>> (d_C);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
printf("warmup <<< %4d %4d >>> elapsed %d sec \n",grid.x,block.x, iElaps );
// run kernel 1
iStart = seconds();
mathKernel1<<<grid, block>>>(d_C);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
printf("mathKernel1 <<< %4d %4d >>> elapsed %d sec \n",grid.x,block.x,iElaps );
// run kernel 3
iStart = seconds();
mathKernel2<<<grid, block>>>(d_C);
cudaDeviceSynchronize();
iElaps = seconds () - iStart;
printf("mathKernel2 <<< %4d %4d >>> elapsed %d sec \n",grid.x,block.x,iElaps );
// run kernel 3
iStart = seconds ();
mathKernel3<<<grid, block>>>(d_C);
cudaDeviceSynchronize();
iElaps = seconds () - iStart;
printf("mathKernel3 <<< %4d %4d >>> elapsed %d sec \n",grid.x,block.x,iElaps);
// run kernel 4
iStart = seconds ();
mathKernel4<<<grid, block>>>(d_C);
cudaDeviceSynchronize();
iElaps = seconds () - iStart;
printf("mathKernel4 <<< %4d %4d >>> elapsed %d sec \n",grid.x,block.x,iElaps);
// free gpu memory and reset divece
cudaFree(d_C);
cudaDeviceReset();
return EXIT_SUCCESS;
}

输出:

$ ./simpleDivergence using Device 0: Tesla M2070
Data size 64 Execution Configuration (block 64 grid 1)
Warmingup elapsed 0.000040 sec
mathKernel1 elapsed 0.000016 sec
mathKernel2 elapsed 0.000014 sec

我们也可以直接使用nvprof这个工具来度量性能:

$ nvprof --metrics branch_efficiency ./simpleDivergence

 输出为:

Kernel: mathKernel1(void)
1 branch_efficiency Branch Efficiency 100.00% 100.00% 100.00%
Kernel: mathKernel2(void)
1 branch_efficiency Branch Efficiency 100.00% 100.00% 100.00%

Branch Efficiency的定义如下:

image

 到这里应该奇怪为什么二者表现相同,实际上当我们代码很简单,可以被预测时,CUDA编译器会自动帮助我们优化代码。稍微提一下GPU分支预测,这里,一个被称为预测变量的东西会被设置成1或者0,所有分支都会得到执行,但是只有预测值为1时才会得到执行。当条件状态少于某一个阈值时,编译器会将分支指令替换为预测指令,因此,现在回到自动优化问题,一份较长的代码就会导致warp divergence了。

6.3 Resource Partitioning

一个warp的context包括以下三部分:

  • Program counter
  • Register
  • Shared memory

在同一个context中切换是没有消耗的,因为在整个warp的生命周期内,SM处理的每个warp执行的contex都是on-chip的。每个SM有一个32位register集合放在register file中,还有固定数量的shared memory,这些资源都被thread瓜分了。由于资源有限,所有,如果thread比较多,那么每个thread占用的资源就较少;thread少,占用资源就较多,这个需要根据自己的要求做出一个平衡。

资源限制了驻留在SM中block的数量,不同device,register和shared memory的数量也不同,比如前文介绍的Fermi和Kepler的差别。如果没有足够的资源,kernel的启动就会失败。

image

 当一个block获得足够的资源时,就成为active block。block中的warp就称为active warp。active warp又可以分为下面三类:

  1. Selected warp
  2. Stalled warp
  3. Eligible warp

 SM中warp调度器每个cycle会挑选active warp送去执行,一个被选中的warp被称为selected warp,没被选中但已经做好准备被执行的称为Eligible warp,没准备好要执行的称为Stalled warp。warp适合执行需要满足下面两个条件:

  1. 32个CUDA core有空
  2. 所有当前指令的参数都准备就绪

例如:kepler任何时刻的active warp数目必须小于或等于64个。selected warp 数目必须小于或等于4个。如果一个warp阻塞了,调度器会挑选一个Eligible warp准备去执行。

CUDA编程中应该重视对计算资源的分配:这些资源限制了active warp的数量。因此,我们必须掌握硬件的一些限制,为了最大化GPU利用率,我们必须最大化active warp的数目。

6.4 Latency Hiding

 指令从开始到结束消耗的clock cycle称为指令的latency。当每个cycle都有eligible warp被调度是,计算资源就会得到充分利用,基于此,我们可以将每个指令的latency隐藏于其它warp的指令过程中。

当涉及到指令latency时,指令可以被区分为下面两种:

  1. Arithmetic instruction
  2. Memory instruction

顾明思义,Arithmetic instruction latency是一个算数操作的始末间隔。另一个则是指load或store的始末间隔。二者的latency大约为:

  1. 10~20 cycles for arithmetic operations
  2. 400~800 cycles for global memory accesses

下图是一个简单的执行流程,当warp0阻塞时,执行其它的warp,当warp变为eligible时重新执行。

image

 如何评估active warps的数量来hide latency?Little’s Law可以提供一个合理的估计:

image

 对于Arithmetic operations来说,并行性可以表示为用来hide Arithmetic latency的操作的数目。下表显示了Fermi和Kepler相关数据,这里以(a+b*c)作为操作的例子。不同的算数指令,throughput(吞吐)也是不同的。

image

 这里的throughput定义为每个SM每个cycle的操作数目。由于每个warp执行同一种指令,因此每个warp对应32个操作。所以,对于Feimi来说,每个SM需要640/32=20个warp来保持计算资源的充分利用。这也意味着,arithmetic operations的并行可以表达为操作的数目或warp的数目。二者的关系也对应了两种方式来增加并行性:

  1. Instruction-level Parallelism(ILP):同一个thread中更多的独立指令
  2. Thread-level Parallelism(TLP):更多并发的eligible threads

对于Memory operations,并行性可以表达为每个cylce的byte数目。

image

 因为memory throughput总是以GB/Sec为单位,我们需要先作相应的转化。可以通过以下指令来查看device的memory frequency:

$ nvidia-smi -a -q -d CLOCK | fgrep -A 3 "Max Clocks" | fgrep "Memory"

以Fermi为例,其memory frequency可能是1.566GHz,Kepler的是1.6GHz。那么转换过程为

144GB/sec÷1.566GHz=92Bytes/Cycle

乘上这个92就得到上图的74,这里的数字是针对device的,而不是每个SM。

有了这些数据,我们就可以做一些计算了。以Fermi为例,加上每个thread的任务是将一个float(4bytes)类型的数据从global memory移至SM用来计算,你应该需要大约18500个thread,也就是597个warp来隐藏所有的memory latency。

74KB÷4 bytes/thread = 18500 threads

18500 threads ÷ 32 threads/warp = 579 warps

Fermi有16个SM,所以每个SM需要579÷16=36个warp来隐藏memory latency。

6.5 Occupancy

当一个warp阻塞了,SM会执行另一个eligible warp。理想情况是,每时每刻到保证cores被占用。Occupacy就是每个SM的active warp 占最大warp数目的比例:

image

 我们可以使用cudaError_t cudaGetDeviceProperties(struct cudaDeviceProp *prop, int device)函数来获取warp最大数目。然后用maxThreadsPerMultiProcessor来获取具体数值。

grid和block的配置准则:

  • 保证block中thread数目是32的倍数。
  • 避免block太小:每个block最少128或256个thread
  • 根据kernel需要的资源调整block。
  • 保证block数目远大于SM的数目。
  • 多做实验来挖掘出最好的配置。

Occupacy专注每个SM中可以并行的thread或者warp数目。不管怎样,Occupacy不是唯一的性能指标,当occupacy达到某个值时,再做优化就不可能有效果了。其实还有许多其它指标需要调节,将在后文继续探讨。

6.6 Synchronize

同步是并行编程的一个普遍的问题。在CUDA的世界里,有两种方式实现同步:

  • System-level:等待所有host和device的工作完成
  • Block-level:等待device中的block中所有thread执行到某个点

因为CUDA API和host代码是异步的,cudaDeviceSynchronize可以用来停住CPU等待CUDA中的操作完成。因为block中的thread执行顺序补丁,CUDA提供了一个function来同步block中的thread:__device__ void __syncthreads(void);。此函数被调用,block中的每个thread都会等待所有其它thread执行到某个点来实现同步。

 7 Kernel性能调节

7.1 代码准备

kernel函数:

__global__ void sumMatrixOnGPU2D(float *A, float *B, float *C, int NX, int NY) {
    unsigned int ix = blockIdx.x * blockDim.x + threadIdx.x;
    unsigned int iy = blockIdx.y * blockDim.y + threadIdx.y;
    unsigned int idx = iy * NX + ix;
    if (ix < NX && iy < NY) {
        C[idx] = A[idx] + B[idx];
    }
}

指定一个比较大的数据矩阵,包含16384个元素:

int nx = 1<<14;
int ny = 1<<14;

通过main函数配置block的维度

if (argc > 2) {
    dimx = atoi(argv[1]);
    dimy = atoi(argv[2]);
}
dim3 block(dimx, dimy);
dim3 grid((nx + block.x - 1) / block.x, (ny + block.y - 1) / block.y);

编译

 nvcc -O3 -arch=sm_20 sumMatrix.cu -o sumMatrix

7.2 使用nvprof检查warps的活动

 在做各项数据比较的时候需要有个基准,这里使用四个block配置的时间消耗作为基准观察,分别为(32,32)(32,16)(16,32)和(16,16)。下面是几种配置的时间消耗输出结果:

$ ./sumMatrix 32 32
sumMatrixOnGPU2D <<< (512,512), (32,32) >>> elapsed 60 ms
$ ./sumMatrix 32 16
sumMatrixOnGPU2D <<< (512,1024), (32,16) >>> elapsed 38 ms
$ ./sumMatrix 16 32
sumMatrixOnGPU2D <<< (1024,512), (16,32) >>> elapsed 51 ms
$ ./sumMatrix 16 16
sumMatrixOnGPU2D <<< (1024,1024),(16,16) >>> elapsed 46 ms

比较这几个结果,不难发现,最慢的是第一个(32,32),最快的是第二个(32,16),这里可以猜测到的是,拥有更多的block并行性更好。这个猜测可以使用nvprof的ahieved_occupancy这个metirc参数来验证。该参数前文已经介绍过。实际上就是每个SM在每个cycle能够达到的最大active warp数目占总数的比例。下面是使用该参数后得到的结果:

$ nvprof --metrics achieved_occupancy ./sumMatrix 32 32
sumMatrixOnGPU2D <<<(512,512), (32,32)>>> Achieved Occupancy 0.501071
$ nvprof --metrics achieved_occupancy ./sumMatrix 32 16
sumMatrixOnGPU2D <<<(512,1024), (32,16)>>> Achieved Occupancy 0.736900
$ nvprof --metrics achieved_occupancy ./sumMatrix 16 32
sumMatrixOnGPU2D <<<(1024,512), (16,32)>>> Achieved Occupancy 0.766037
$ nvprof --metrics achieved_occupancy ./sumMatrix 16 16
sumMatrixOnGPU2D <<<(1024,1024),(16,16)>>> Achieved Occupancy 0.810691

从上面的输出可以得知两件事:

  • 由于第二个配置比第一个配置有更多的block,device就会达到更多active warp。也就是第二个性能优于第一个的原因。
  • 第四个的achieved Occupacy最高,但不是最快的。因此,较高的achieved Occupacy并不一定就意味着更好的性能,也就是说还有更多的因素影响GPU的性能。

 7.3 用nvprof检查内存操作

对于C[idx] = A[idx] + B[idx]来说共有三个memory操作:两个memory load和一个memory store。要查看这些操作的效率可以使用nvprof的两个metric参数,如果想要查看memory的throughput,则可使用gld_throughput:

$ nvprof --metrics gld_throughput./sumMatrix 32 32
sumMatrixOnGPU2D <<<(512,512), (32,32)>>> Global Load Throughput 35.908GB/s
$ nvprof --metrics gld_throughput./sumMatrix 32 16
sumMatrixOnGPU2D <<<(512,1024), (32,16)>>> Global Load Throughput 56.478GB/s
$ nvprof --metrics gld_throughput./sumMatrix 16 32
sumMatrixOnGPU2D <<<(1024,512), (16,32)>>> Global Load Throughput 85.195GB/s
$ nvprof --metrics gld_throughput./sumMatrix 16 16
sumMatrixOnGPU2D <<<(1024,1024),(16,16)>>> Global Load Throughput 94.708GB/s

不难看出,第四个拥有最高的load throughput,但是却比第二个慢(第二个也就是第四个的一半),所以,较高的load throughput也不一定就有较高的性能。之后讲到memory transaction时会具体分析这种现象的原因。简单说,就是高load throughput有可能是一种假象,如果需要的数据在memory中存储格式未对齐、不连续,则会导致不必要的load操作,所以本文的efficiency会这么低。

然后,我们可以使用nvprof的gld_efficiency来度量load efficiency,该metric参数是指我们确切需要的global load throughput与实际得到的global load memory的比值。这个metric参数就可以让我们知道,APP的load操作利用device memory bandwidth的程度:

$ nvprof --metrics gld_efficiency ./sumMatrix 32 32
sumMatrixOnGPU2D <<<(512,512), (32,32)>>> Global Memory Load Efficiency 100.00%
$ nvprof --metrics gld_efficiency ./sumMatrix 32 16
sumMatrixOnGPU2D <<<(512,1024), (32,16)>>> Global Memory Load Efficiency 100.00%
$ nvprof --metrics gld_efficiency ./sumMatrix 16 32
sumMatrixOnGPU2D <<<(1024,512), (16,32)>>> Global Memory Load Efficiency 49.96%
$ nvprof --metrics gld_efficiency ./sumMatrix 16 16
sumMatrixOnGPU2D <<<(1024,1024),(16,16)>>> Global Memory Load Efficiency 49.80%

从上述结果可知,最好两个的load efficiency只是前两个的一半。这也可以解释,为什么较高的throughput和较高的Occupacy却没有产生较好的性能。尽管最后两个的load操作数目要多不少(因为二者的throughput较高),但是他们的load effecitiveness却低不少(由于efficiency较低)

观察最后两个可以发现,他们的block的x象限配置是warp的一般,由前文推测可知,该象限应该保持未warp大小的整数倍。关于具体原因将在后续文中详细解释。

 7.4 其它并行性

我们现在可以得出一个结论就是blockDim.x应该是warp大小的整数倍,这样做是很容易提升load efficiency。现在,我们还有其它疑惑,比如:

  • 继续调整blockDim.x是否会继续增加load throughput?
  • 还有其他方法能增大并行性吗?

现在,我们重新调整一个基准数据出来,这两个问题可以从这个基准分析个大概:

$ ./sumMatrix 64 2
sumMatrixOnGPU2D <<<(256,8192), (64,2) >>> elapsed 0.033567 sec
$ ./sumMatrix 64 4
sumMatrixOnGPU2D <<<(256,4096), (64,4) >>> elapsed 0.034908 sec
$ ./sumMatrix 64 8
sumMatrixOnGPU2D <<<(256,2048), (64,8) >>> elapsed 0.036651 sec
$ ./sumMatrix 128 2
sumMatrixOnGPU2D <<<(128,8192), (128,2)>>> elapsed 0.032688 sec
$ ./sumMatrix 128 4
sumMatrixOnGPU2D <<<(128,4096), (128,4)>>> elapsed 0.034786 sec
$ ./sumMatrix 128 8
sumMatrixOnGPU2D <<<(128,2048), (128,8)>>> elapsed 0.046157 sec
$ ./sumMatrix 256 2
sumMatrixOnGPU2D <<<(64,8192), (256,2)>>> elapsed 0.032793 sec
$ ./sumMatrix 256 4
sumMatrixOnGPU2D <<<(64,4096), (256,4)>>> elapsed 0.038092 sec
$ ./sumMatrix 256 8
sumMatrixOnGPU2D <<<(64,2048), (256,8)>>> elapsed 0.000173 sec
Error: sumMatrix.cu:163, code:9, reason: invalid configuration argument

从上面的数据,我们可以分析出来的是:

  •  最后一个配置(256,8)不可行,block中总共的thread数目超过了1024,这是GPU的硬件限制。
  • 最好的结果是第四个(128,2)。
  • 第一个启动了最多block,但不是最快的。
  • 因为第二个与第四个在一个block中拥有相同数目的thread,本应该猜测二者拥有相同的表现,但是实际却是第二个略逊色,所以blockDim.x的大小是关键。
  • 剩下的相对第四个都拥有较少的block数目,所以并行规模也是影响性能的关键因素。

现在,我们推测,拥有block最少的应该会有一个最低的achieved Occupacy,而拥有最多的block应该会到达最高的achieved Occupacy:

$ nvprof --metrics achieved_occupancy ./sumMatrix 64 2
sumMatrixOnGPU2D <<<(256,8192), (64,2) >>> Achieved Occupancy 0.554556
$ nvprof --metrics achieved_occupancy ./sumMatrix 64 4
sumMatrixOnGPU2D <<<(256,4096), (64,4) >>> Achieved Occupancy 0.798622
$ nvprof --metrics achieved_occupancy ./sumMatrix 64 8
sumMatrixOnGPU2D <<<(256,2048), (64,8) >>> Achieved Occupancy 0.753532
$ nvprof --metrics achieved_occupancy ./sumMatrix 128 2
sumMatrixOnGPU2D <<<(128,8192), (128,2)>>> Achieved Occupancy 0.802598
$ nvprof --metrics achieved_occupancy ./sumMatrix 128 4
sumMatrixOnGPU2D <<<(128,4096), (128,4)>>> Achieved Occupancy 0.746367
$ nvprof --metrics achieved_occupancy ./sumMatrix 128 8
sumMatrixOnGPU2D <<<(128,2048), (128,8)>>> Achieved Occupancy 0.573449
$ nvprof --metrics achieved_occupancy ./sumMatrix 256 2
sumMatrixOnGPU2D <<<(64,8192), (256,2) >>> Achieved Occupancy 0.760901
$ nvprof --metrics achieved_occupancy ./sumMatrix 256 4
sumMatrixOnGPU2D <<<(64,4096), (256,4) >>> Achieved Occupancy 0.595197

实际上(64,2)的Achieved Occupacy竟然是最低,经过他有最多的block,它达到了硬件对block数量的限制。

第四个(128,2)和第七个(256,2)拥有差不多的Achieved Occupacy。我们对这两个再做一个试验,再次增大,将blockDim.y设置未了,也就减少了block的大小:

$ ./sumMatrix 128 1
sumMatrixOnGPU2D <<<(128,16384),(128,1)>>> elapsed 0.032602 sec
$ ./sumMatrix 256 1
sumMatrixOnGPU2D <<<(64,16384), (256,1)>>> elapsed 0.030959 sec

这次配置产生了最佳的性能,特别是(256,1)要比(128,1)要更好,再次检查achieved occupacy,load throughput和load efficiency:

$ nvprof --metrics achieved_occupancy ./sumMatrix 256 1
$ nvprof --metrics gld_throughput ./sumMatrix 256 1
$ nvprof --metrics gld_efficiency ./sumMatrix 256 1

输出:

Achieved Occupancy 0.808622
Global Load Throughput 69.762GB/s
Global Memory Load Efficiency 100.00%

可以看出,最佳配置既不是拥有最高的achieved occupacy,也不是最高laod throughput的,所以不存在唯一metric来优化计算性能,而是需要从众多metric中寻求一个平衡。

总结:

  • 在大多数情况下,并不存在唯一的metric可以精确的优化性能。
  • 哪个metric或者event对性能影响大是由kernel具体代码决定的。
  • 在众多的metric和event中寻求一个平台。
  • Grid/block heuristics(启发)为调节性能提供了不错的切入点。

 8 分支分歧与循环展开

8.1 避免分支分歧

有时,控制依赖于thread索引。同一个warp中,一个条件分支可能告知很差的性能。通过重新组织数据获取模式可以减少或避免warp divergence。

8.2 并行规约问题

我们现在计算一个数组N个元素的和。这个过程用CPU编程很容易实现:

int sum = 0;
for (int i = 0; i < N; i++)
    sum += array[i];

那么,如果array的元素非常多呢?应用并行计算可以大大提升这个过程的效率。鉴于甲方的交换律等性质。这个求和过程可以以元素的任意顺序来进行:

  • 将输入数组切割成很多小的块。
  • 用thread来计算每个块的和。
  • 对这些块的结果再求和得最终结果。

数组的切割主旨是,用thread求数组中按一定规律配对的两个元素和,然后将所有结果组合成一个新的数组,然后再次求配对两元素和,多次迭代,直到数组中只有一个结果。

比较直观的两种实现方式:

  1. Neighbored pair:每次迭代都是相邻两个元素求和。
  2. Interleaved pair:按一定跨度配对两个元素。

下图展示了两种方法的求解过程,对于有N个元素的数组,这个过程需要N-1此求和,Log(N)步。Interleaved pair的跨度是半个数组长度。

image

 下面是用递归实现的Interleaved pair代码:

int recursiveReduce(int *data, int const size) {
    // terminate check
    if (size == 1) return data[0];
    // renew the stride
    int const stride = size / 2;
    // in-place reduction
    for (int i = 0; i < stride; i++) {
        data[i] += data[i + stride];
    }
    // call recursively
    return recursiveReduce(data, stride);
}

上述讲的这类问题术语叫规约问题。Parallel reduction(并行规约)是指迭代减少操作,是并行算法中非常关键的一种操作。

8.3 并行规约中的发散

这部分以neighbored pair 为参考研究:

image

 在这kernel里面,有两个global memory array,一个用来存放数组所有数据,另一个用来存放部分和。所有block独立的执行求和操作。__syncthreads用来保证每次迭代,所有的求和操作都做完,然后进入下一步迭代。

__global__ void reduceNeighbored(int *g_idata, int *g_odata, unsigned int n) {
    // set thread ID
    unsigned int tid = threadIdx.x;
    // convert global data pointer to the local pointer of this block
    int *idata = g_idata + blockIdx.x * blockDim.x;
    // boundary check
    if (idx >= n) return;
        // in-place reduction in global memory
    for (int stride = 1; stride < blockDim.x; stride *= 2) {
        if ((tid % (2 * stride)) == 0) {
            idata[tid] += idata[tid + stride];
        }
        // synchronize within block
        __syncthreads();
    }
    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = idata[0];
}

因为没有办法让所有的block同步,所以最后将所有block的结果送回host来进行串行计算,如下图所示:

image

 main代码:

int main(int argc, char **argv) {
// set up device
int dev = 0;
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, dev);
printf("%s starting reduction at ", argv[0]);
printf("device %d: %s ", dev, deviceProp.name);
cudaSetDevice(dev);
bool bResult = false;
// initialization
int size = 1<<24; // total number of elements to reduce
printf(" with array size %d ", size);
// execution configuration
int blocksize = 512; // initial block size
if(argc > 1) {
blocksize = atoi(argv[1]); // block size from command line argument
}
dim3 block (blocksize,1);
dim3 grid ((size+block.x-1)/block.x,1);
printf("grid %d block %d\n",grid.x, block.x);
// allocate host memory
size_t bytes = size * sizeof(int);
int *h_idata = (int *) malloc(bytes);
int *h_odata = (int *) malloc(grid.x*sizeof(int));
int *tmp = (int *) malloc(bytes);
// initialize the array
for (int i = 0; i < size; i++) {
// mask off high 2 bytes to force max number to 255
h_idata[i] = (int)(rand() & 0xFF);
}
memcpy (tmp, h_idata, bytes);
size_t iStart,iElaps;
int gpu_sum = 0;
// allocate device memory
int *d_idata = NULL;
int *d_odata = NULL;
cudaMalloc((void **) &d_idata, bytes);
cudaMalloc((void **) &d_odata, grid.x*sizeof(int));
// cpu reduction
iStart = seconds ();
int cpu_sum = recursiveReduce(tmp, size);
iElaps = seconds () - iStart;
printf("cpu reduce elapsed %d ms cpu_sum: %d\n",iElaps,cpu_sum);
// kernel 1: reduceNeighbored
cudaMemcpy(d_idata, h_idata, bytes, cudaMemcpyHostToDevice);
cudaDeviceSynchronize();
iStart = seconds ();
warmup<<<grid, block>>>(d_idata, d_odata, size);
cudaDeviceSynchronize();
iElaps = seconds () - iStart;
cudaMemcpy(h_odata, d_odata, grid.x*sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i=0; i<grid.x; i++) gpu_sum += h_odata[i];
printf("gpu Warmup elapsed %d ms gpu_sum: %d <<<grid %d block %d>>>\n",
iElaps,gpu_sum,grid.x,block.x);
// kernel 1: reduceNeighbored
cudaMemcpy(d_idata, h_idata, bytes, cudaMemcpyHostToDevice);
cudaDeviceSynchronize();
iStart = seconds ();
reduceNeighbored<<<grid, block>>>(d_idata, d_odata, size);
cudaDeviceSynchronize();
iElaps = seconds () - iStart;
cudaMemcpy(h_odata, d_odata, grid.x*sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i=0; i<grid.x; i++) gpu_sum += h_odata[i];
printf("gpu Neighbored elapsed %d ms gpu_sum: %d <<<grid %d block %d>>>\n",
iElaps,gpu_sum,grid.x,block.x);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
cudaMemcpy(h_odata, d_odata, grid.x/8*sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x / 8; i++) gpu_sum += h_odata[i];
printf("gpu Cmptnroll elapsed %d ms gpu_sum: %d <<<grid %d block %d>>>\n",
iElaps,gpu_sum,grid.x/8,block.x);
/// free host memory
free(h_idata);
free(h_odata);
// free device memory
cudaFree(d_idata);
cudaFree(d_odata);
// reset device
cudaDeviceReset();
// check the results
bResult = (gpu_sum == cpu_sum);
if(!bResult) printf("Test failed!\n");
return EXIT_SUCCESS;
}

 初始化数组,使其包含16M元素:

int size = 1<<24;

kernel配置为1D grid和1D block:

dim3 block (blocksize, 1);
dim3 block ((siize + block.x – 1) / block.x, 1);

编译:

$ nvcc -O3 -arch=sm_20 reduceInteger.cu -o reduceInteger

运行:

$ ./reduceInteger starting reduction at device 0: Tesla M2070
with array size 16777216 grid 32768 block 512
cpu reduce elapsed 29 ms cpu_sum: 2139353471
gpu Neighbored elapsed 11 ms gpu_sum: 2139353471 <<<grid 32768 block 512>>>
Improving Divergence in Parallel Reduction

考虑上节if判断条件:

if ((tid % (2 * stride)) == 0)

因为这表达式只对偶数ID的线程为true,所以其导致很高的divergent warps。第一次迭代只有偶数ID的线程执行了指令,但是所有线程都要被调度;第二次迭代,只有四分之一的thread是active的,但是所有的thread仍然要被调度。

我们可以重新组织每个线程对应的数组索引来强制id相邻的thread来处理求和操作。如下图所示(主要图中的thread id与上一个图的区别):

image

 

 新的代码:

__global__ void reduceNeighboredLess (int *g_idata, int *g_odata, unsigned int n) {
    // set thread ID
    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
    // convert global data pointer to the local pointer of this block
    int *idata = g_idata + blockIdx.x*blockDim.x;
    // boundary check
    if(idx >= n) return;
    // in-place reduction in global memory
    for (int stride = 1; stride < blockDim.x; stride *= 2) {
        // convert tid into local array index
        int index = 2 * stride * tid;
        if (index < blockDim.x) {
            idata[index] += idata[index + stride];
        }    
        // synchronize within threadblock
        __syncthreads();
    }
    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = idata[0];
}

注意这行代码:

int index = 2 * stride * tid;

因为步调乘以了2,下面的语句使用block的前半部分thread来执行求和:

if (index < blockDim.x)

对于一个有512个thread的block来说,前八个warp执行第一轮reduction,剩下八个warp什么也不干;第二轮,前4个warp执行,剩下12个warp什么也不干。因此,就彻底不存在divegence了(重申:divergence只发生于同一个warp)。最后的五轮还是会导致divergence,因为这个时候需要执行thread已经凑不够一个warp了。

// kernel 2: reduceNeighbored with less divergence
cudaMemcpy(d_idata, h_idata, bytes, cudaMemcpyHostToDevice);
cudaDeviceSynchronize();
iStart = seconds();
reduceNeighboredLess<<<grid, block>>>(d_idata, d_odata, size);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
cudaMemcpy(h_odata, d_odata, grid.x*sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i=0; i<grid.x; i++) gpu_sum += h_odata[i];
printf("gpu Neighbored2 elapsed %d ms gpu_sum: %d <<<grid %d block %d>>>\n",iElaps,gpu_sum,grid.x,block.x);

运行结果:

$ ./reduceInteger Starting reduction at device 0: Tesla M2070
vector size 16777216 grid 32768 block 512
cpu reduce elapsed 0.029138 sec cpu_sum: 2139353471
gpu Neighbored elapsed 0.011722 sec gpu_sum: 2139353471 <<<grid 32768 block 512>>>
gpu NeighboredL elapsed 0.009321 sec gpu_sum: 2139353471 <<<grid 32768 block 512>>>

新的实现比原来的快了1.26。我们也可以使用nvprof的inst_per_warp参数来查看每个warp上执行的指令数目的平均值。

$ nvprof --metrics inst_per_warp ./reduceInteger

输出,原来的是新kernel的两倍还多,因为原来的有许多不必要的操作也执行了:

Neighbored Instructions per warp 295.562500
NeighboredLess Instructions per warp 115.312500

在查看throughput:

$ nvprof --metrics gld_throughput ./reduceInteger

输出,新的kernel拥有更大的throughput,因为虽然I/O操作数目相同,但是其消耗时间短:

Neighbored Global Load Throughput 67.663GB/s
NeighboredL Global Load Throughput 80.144GB/s
Reducing with Interleaved Pairs

 Interleaved pair模式的初始步调是block大小的一半,每个thread处理像半个block的两个数据之和。和之前的图示相比,工作的thread数目没有变化。但是每个thread的load/store global memory的位置是不同的。

Interleaved pair的kernel实现:

/// Interleaved Pair Implementation with less divergence
__global__ void reduceInterleaved (int *g_idata, int *g_odata, unsigned int n) {
// set thread ID
unsigned int tid = threadIdx.x;
unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
// convert global data pointer to the local pointer of this block
int *idata = g_idata + blockIdx.x * blockDim.x;
// boundary check
if(idx >= n) return;
// in-place reduction in global memory
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
idata[tid] += idata[tid + stride];
}
__syncthreads();
}
// write result for this block to global mem
if (tid == 0) g_odata[blockIdx.x] = idata[0];
}

image

注意下面的语句,步调被初始化为block大小的一半: 

for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {

下面的语句使得第一次迭代时,block的前半部分thread执行相加操作,第二次是前四分之一,以此类推:

if (tid < stride)

下面是加入main的代码:

cudaMemcpy(d_idata, h_idata, bytes, cudaMemcpyHostToDevice);
cudaDeviceSynchronize();
iStart = seconds();
reduceInterleaved <<< grid, block >>> (d_idata, d_odata, size);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
cudaMemcpy(h_odata, d_odata, grid.x*sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x; i++) gpu_sum += h_odata[i];
printf("gpu Interleaved elapsed %f sec gpu_sum: %d <<<grid %d block %d>>>\n",iElaps,gpu_sum,grid.x,block.x);

输出:

$ ./reduce starting reduction at device 0: Tesla M2070
with array size 16777216 grid 32768 block 512
cpu reduce elapsed 0.029138 sec cpu_sum: 2139353471
gpu Warmup elapsed 0.011745 sec gpu_sum: 2139353471 <<<grid 32768 block 512>>>
gpu Neighbored elapsed 0.011722 sec gpu_sum: 2139353471 <<<grid 32768 block 512>>>
gpu NeighboredL elapsed 0.009321 sec gpu_sum: 2139353471 <<<grid 32768 block 512>>>
gpu Interleaved elapsed 0.006967 sec gpu_sum: 2139353471 <<<grid 32768 block 512>>>

这次相对第一个kernel又快了1.69,比第二个也快了1.34.这个效果主要由global memory的load/store模式导致的。

8.4 循环展开

loop unrolling 是用来优化循环,减少分支的方法,该方法简单说就是把本应该在多次loop中完成的操作,尽量压缩到一次loop。循环体展开的程度称为loop unrolling factor(循环展开因子),loop unrolling对于顺序数组操作性能影响很大,考虑如下代码:

for (int i = 0; i < 100; i++) {
    a[i] = b[i] + c[i];
}

如下重复一次循环体操作,迭代数目将减少一半:

for (int i = 0; i < 100; i += 2) {
    a[i] = b[i] + c[i];
    a[i+1] = b[i+1] + c[i+1];
}   

从高级语言层面是无法看出性能提升的原因的,需要从low-level instruction层面去分析,第二阶段循环次数减少了一半,而循环体两句语句的读写操作的执行在CPU上市可以同时执行、互相独立的,所以相对第一段,第二段性能要更好。

Unrolling在CUDA编程中意义更重。我们的目标依然是通过减少指令执行消耗,增加更多的独立指令来提高性能。这样就会增加更多的并行操作从而产生更高的智力和内地带宽(bandwith)。也就提供了更多的eligible warps来帮助hide instruction/memory latency。

8.5 规约展开

在前文的reduce Interleaved中,每个block处理一部分数据,我们给这数据起名data block。下面的代码是reduceInterleaved的修正版本,每个block都是以两个data block作为源数据进行操作(前文中,每个block处理一个data block)。这是一种cyclic partitioning:每个thread作用于多个data block,并且从每个data block中取出一个元素处理。

__global__ void reduceUnrolling2 (int *g_idata, int *g_odata, unsigned int n) {
    // set thread ID
    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * blockDim.x * 2 + threadIdx.x;

    // convert global data pointer to the local pointer of this block
    int *idata = g_idata + blockIdx.x * blockDim.x * 2;

    // unrolling 2 data blocks
    if (idx + blockDim.x < n) g_idata[idx] += g_idata[idx + blockDim.x];
    __syncthreads();

    // in-place reduction in global memory
    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) {
            idata[tid] += idata[tid + stride];
        }
        // synchronize within threadblock
        __syncthreads();
    }

    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = idata[0];
}

注意下面的语句,每个thread从相邻的data block中取数据,这一步实际上就是将两个data block规约成一个。

if (idx + blockDim.x < n) g_idata[idx] += g_idata[idx+blockDim.x];

global array index也要相应的调整。因为,相对之前的版本,同样的数据,我们只需要原来一半的thread就能解决问题。要注意的是,这样做也会降低warp或block的并行性(因为thread少了):

image

 main增加代码如下:

cudaMemcpy(d_idata, h_idata, bytes, cudaMemcpyHostToDevice);
cudaDeviceSynchronize();
iStart = seconds();
reduceUnrolling2 <<< grid.x/2, block >>> (d_idata, d_odata, size);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
cudaMemcpy(h_odata, d_odata, grid.x/2*sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x / 2; i++) gpu_sum += h_odata[i];
printf("gpu Unrolling2 elapsed %f sec gpu_sum: %d <<<grid %d block %d>>>\n",iElaps,gpu_sum,grid.x/2,block.x);

 

 

posted @ 2026-01-11 09:43  钟齐峰  阅读(10)  评论(0)    收藏  举报