CUDA - 如何在CUDA C/C++内核中高效地访问全局内存

原文链接:How to Access Global Memory Efficiently in CUDA C/C++ Kernels


在前两篇文章(第一篇第二篇)中,我们研究了如何在主机和设备之间高效地移动数据。在这个CUDA C/C++系列的第六篇文章中,我们将讨论如何从内核中高效地访问设备内存,特别是全局内存(global memory)

CUDA设备上有几种内存,每种内存都有不同的作用域、生存期和缓存行为。到目前为止,在本系列文章中,我们已经使用了全局内存,它位于设备DRAM中,用于主机和设备之间的传输以及内核的数据输入源和输出位置。这里的名称全局(global) 指的是作用域,因为从主机和设备都可以访问和修改它。全局内存可以在全局(变量)作用域中使用__device__声明说明符进行声明,如以下代码段的第一行所示;也可以使用cudaMalloc()动态开辟全局内存并分配给常规C指针变量,如第7行所示。开辟的全局内存在应用程序的生存期内持续存在。根据设备的计算能力,全局内存有可能被高速缓存在芯片上。

__device__ int globalArray[256];
 
void foo()
{
    ...
    int *myDeviceMemory = 0;
    cudaError_t result = cudaMalloc(&myDeviceMemory, 256 * sizeof(int));
    ...
}

在我们讨论全局内存访问性能之前,我们需要完善对CUDA执行模型(CUDA execution model)的理解。我们已经讨论了如何将线程(threads)分组为线程块(thread blocks),这些线程块被分配给设备上的multiprocessor。在执行过程中,线程被更精细地分组为束(warps)。GPU上的multiprocessor以SIMD(Single Instruction Multiple Data, 单指令多数据)方式为每个warp执行指令。所有当前具有CUDA功能的GPU的warp size(实际上是SIMD width)是32个线程。

全局内存合并(coalesce)

将线程分组为warp不仅与计算有关,还与全局内存访问有关。设备将warp内的线程发出的全局内存加载和存储行为合并为尽可能少的事务(transaction),以最大限度地减少DRAM带宽(在计算能力低于2.0的旧硬件上,事务合并发生在16个线程的半warp内,而不是整个warp内)。为了明确不同CUDA设备架构的发生合并的条件,我们在三个Tesla卡上进行了一些简单的实验:Tesla C870(计算能力1.0)、Tesla C1060(计算能力1.3)和Tesla C2050(计算能力2.0)。

我们进行了两个实验,使用了以下代码中显示的做自增运算的内核的两个变形(也可在GitHub上获得),一个是数组偏移,可能会导致对输入数组的不对齐访问,另一个是对输入数组进行跨步访问。

#include <stdio.h>
#include <assert.h>

// Convenience function for checking CUDA runtime API results
// can be wrapped around any runtime API call. No-op in release builds.
inline
cudaError_t checkCuda(cudaError_t result)
{
#if defined(DEBUG) || defined(_DEBUG)
  if (result != cudaSuccess) {
    fprintf(stderr, "CUDA Runtime Error: %s\n", cudaGetErrorString(result));
    assert(result == cudaSuccess);
  }
#endif
  return result;
}

template <typename T>
__global__ void offset(T* a, int s)
{
  int i = blockDim.x * blockIdx.x + threadIdx.x + s;
  a[i] = a[i] + 1;
}

template <typename T>
__global__ void stride(T* a, int s)
{
  int i = (blockDim.x * blockIdx.x + threadIdx.x) * s;
  a[i] = a[i] + 1;
}

template <typename T>
void runTest(int deviceId, int nMB)
{
  int blockSize = 256;
  float ms;

  T *d_a;
  cudaEvent_t startEvent, stopEvent;
    
  int n = nMB*1024*1024/sizeof(T);

  // NB:  d_a(33*nMB) for stride case
  checkCuda( cudaMalloc(&d_a, n * 33 * sizeof(T)) );

  checkCuda( cudaEventCreate(&startEvent) );
  checkCuda( cudaEventCreate(&stopEvent) );

  printf("Offset, Bandwidth (GB/s):\n");
  
  offset<<<n/blockSize, blockSize>>>(d_a, 0); // warm up

  for (int i = 0; i <= 32; i++) {
    checkCuda( cudaMemset(d_a, 0, n * sizeof(T)) );

    checkCuda( cudaEventRecord(startEvent,0) );
    offset<<<n/blockSize, blockSize>>>(d_a, i);
    checkCuda( cudaEventRecord(stopEvent,0) );
    checkCuda( cudaEventSynchronize(stopEvent) );

    checkCuda( cudaEventElapsedTime(&ms, startEvent, stopEvent) );
    printf("%d, %f\n", i, 2*nMB/ms);
  }

  printf("\n");
  printf("Stride, Bandwidth (GB/s):\n");

  stride<<<n/blockSize, blockSize>>>(d_a, 1); // warm up
  for (int i = 1; i <= 32; i++) {
    checkCuda( cudaMemset(d_a, 0, n * sizeof(T)) );

    checkCuda( cudaEventRecord(startEvent,0) );
    stride<<<n/blockSize, blockSize>>>(d_a, i);
    checkCuda( cudaEventRecord(stopEvent,0) );
    checkCuda( cudaEventSynchronize(stopEvent) );

    checkCuda( cudaEventElapsedTime(&ms, startEvent, stopEvent) );
    printf("%d, %f\n", i, 2*nMB/ms);
  }

  checkCuda( cudaEventDestroy(startEvent) );
  checkCuda( cudaEventDestroy(stopEvent) );
  cudaFree(d_a);
}

int main(int argc, char **argv)
{
  int nMB = 4;
  int deviceId = 0;
  bool bFp64 = false;

  for (int i = 1; i < argc; i++) {    
    if (!strncmp(argv[i], "dev=", 4))
      deviceId = atoi((char*)(&argv[i][4]));
    else if (!strcmp(argv[i], "fp64"))
      bFp64 = true;
  }
  
  cudaDeviceProp prop;
  
  checkCuda( cudaSetDevice(deviceId) )
  ;
  checkCuda( cudaGetDeviceProperties(&prop, deviceId) );
  printf("Device: %s\n", prop.name);
  printf("Transfer size (MB): %d\n", nMB);
  
  printf("%s Precision\n", bFp64 ? "Double" : "Single");
  
  if (bFp64) runTest<double>(deviceId, nMB);
  else       runTest<float>(deviceId, nMB);
}

这段代码可以通过传递fp64命令行选项以单精度(默认值)或双精度运行offsetstride内核。每个内核接受两个参数,一个输入数组和一个整数,整数表示用于访问数组元素的偏移量或步长。内核在一系列偏移和步长上循环调用。

不对齐数据访问

offset内核在Tesla C870、C1060和C2050上的结果如下图所示。
在这里插入图片描述
CUDA驱动程序将设备内存中分配的数组与256字节内存段(memory segments)对齐。设备可以通过与其大小对齐的32、64或128字节事务访问全局内存。对于C870或计算能力为1.0的任何其他设备,半个warp的线程的任何不对齐访问(或半个warp的线程不按顺序访问内存的对齐访问)都会导致16个独立的32字节事务。由于每个32字节事务只请求4个字节,因此当offset不是16个元素的倍数(对应于半个warp的线程数)时,预计有效带宽将减少为原来的1/8(半个warp一共请求4*16字节的数据,不对齐访问由于使用了16个独立的32字节事务,需要占用32*16字节的带宽,因此是1/8,译者注),如上图(棕色线)所示。

对于Tesla C1060或其他计算能力为1.2或1.3的设备,未对齐访问的问题较小。基本上,半个warp的线程对连续数据的未对齐访问是由“覆盖(cover)”所请求数据的少量事务来提供服务的。由于传输了未请求的数据以及不同的half-warp所请求的数据的一些重叠,因此相对于对齐的情况,仍然存在性能损失,但损失远小于C870。

计算能力为2.0的设备,如Tesla C2050,在每个line size为128字节的multiprocessor中都有一个L1缓存。这类设备将一个warp的线程的访问合并为尽可能少的缓存行(cache line),从而使线程间的顺序内存访问的吞吐量受是否对齐的影响可以忽略不计。

跨步(stride)内存访问

stride内核的结果如下图所示。
在这里插入图片描述
对于跨步式的全局内存访问,其结果与之前的不同。对于大的stride,无论哪种架构,有效带宽都很低。这并不奇怪:当并发线程同时访问物理内存中相距很远的内存地址时,硬件没有机会将访问进行合并。你可以在上图中看到,在Tesla C870上,任何除了1之外的stride都会导致有效带宽大幅降低。这是因为计算能力1.0和1.1的硬件需要线程间进行线性、对齐的访问从而对访问进行合并,因此我们看到了在offset内核结果中熟悉的1/8带宽。计算能力1.2及更高版本的硬件可以将对对齐的段(segment)的访问合并(计算能力1.2/1.3的设备上为32、64或128字节segment,计算能力2.0及更高的设备上为128字节cache line),因此这类硬件的带宽曲线是平滑的。

访问多维数组时,线程通常需要对数组的较高维度进行索引,因此跨步访问是不可避免的。我们可以通过使用一种称为共享内存的CUDA内存来处理这些情况。共享内存是由线程块(block)中的所有线程共享的片上内存(on-chip memory)。共享内存的一个用途是以合并的方式将多维数组的2D数据片(2D tile)从全局内存中提取到共享内存中,然后使连续的线程跨过共享内存数据片。与全局内存不同,跨步访问共享内存不会带来额外开销。我们将在下一篇文章中详细介绍共享内存。

总结

在这篇文章中,我们讨论了如何从CUDA内核代码中有效地访问全局内存的一些方面。设备上的全局内存访问与主机上的数据访问有相同的性能特性;即数据位置非常重要。在早期的CUDA硬件中,内存访问对齐与线程间的位置一样重要,但在最近的硬件中,对齐并不重要。另一方面,跨步式内存访问会损害性能,使用片上共享内存可以缓解这种情况。在下一篇文章中,我们将详细探讨共享内存,再下一篇,将展示如何使用共享内存来避免矩阵转置时的跨步全局内存访问。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
CUDA是一个用于并行计算的平台和编程模型,专门设计用于NVIDIA GPU。在编译CUDA程序时,可以采取一些优化措施以提高性能。以下是一些常用的CUDA编译优化技术: 1. 使用合适的编译器选项:在编译CUDA程序时,可以通过选择合适的编译器选项来控制优化级别。例如,可以使用-O3选项开启所有优化选项,或者使用-O2选项开启一组常用的优化选项。 2. 使用合适的函数修饰符:CUDA提供了一些函数修饰符,如__global__和__device__,用于标识并行执行的函数和设备函数。正确使用这些修饰符可以帮助编译器进行更好的优化。 3. 减少内存访问:由于GPU的计算能力通常比内存访问能力更强,减少内存访问可以提高性能。可以尝试通过使用共享内存、本地内存等技术来减少全局内存访问。 4. 矢量化和并行化:CUDA编译器可以自动将某些代码向量化或并行化,以提高执行效率。可以尝试使用适当的数据结构和算法,以促进矢量化和并行化。 5. 优化存储器访问模式:在CUDA程序,可以尝试通过访问连续内存块、使用缓存和纹理内存等技术来优化存储器访问模式。 6. 使用计算能力特定的优化:不同的NVIDIA GPU具有不同的计算能力和架构特点。可以根据目标设备的计算能力和架构特点,针对性地进行优化。 需要注意的是,CUDA编译优化是一个复杂的主题,具体的优化策略和技术可能因应用场景而异。在实际开发过程,可以结合性能分析工具和调试器,进行针对性的优化和性能调优。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值