CUDA C/C++ 教程一:加速应用程序(下)

简介: CUDA C/C++ 教程一:加速应用程序(下)

4. 分配可同时被GPU和CPU访问的内存


CUDA 的最新版本(版本 6 和更高版本)可以便捷地分配和释放既可用于 Host 也可被 Device 访问的内存。


在 Host(CPU)中,我们一般适用malloc 和 free 来分配和释放内存,但这样分配的内存无法直接被Device(GPU)访问,所以在这里我们用cudaMallocManaged 和 cudaFree 两个函数来分配和释放同时可被 Host 和 Device 访问的内存。如下例所示:


// CPU
int N = 10;
size_t size = N * sizeof(int);
int *a;
a = (int *)malloc(size); // 分配CPU内存
free(a); // 释放CPU内存


// GPU
int N = 10;
size_t size = N * sizeof(int);
int *a;
cudaMallocManaged(&a, size);// 为a分配CPU和GPU内存
cudaFree(a); // 释放GPU内存


实际上,cudaMallocManaged在统一内存中创建了一个托管内存池(CPU上有,GPU上也有),内存池中已分配的空间可以通过相同的指针直接被CPU和GPU访问,底层系统在统一的内存空间中自动地在设备和主机间进行传输。数据传输对应用来说是透明的,大大简化了代码。


现在让我们来看看如何利用GPU来执行数组元素的乘法操作:


#include <stdio.h>
// 初始化数组
void init(int *a, int N) {
  int i;
  for (i = 0; i < N; ++i) {
    a[i] = i;
  }
}
// CUDA 核函数,所有元素乘2
__global__ void doubleElements(int *a, int N) {
  int i;
  i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < N) {
    a[i] *= 2;
  }
}
// 检查数组内所有元素的值是否均为复数
bool checkElementsAreDoubled(int *a, int N) {
  int i;
  for (i = 0; i < N; ++i) {
    if (a[i] != i*2) return false;
  }
  return true;
}
int main() {
  int N = 1000;
  int *a;
  size_t size = N * sizeof(int);
  cudaMallocManaged(&a, size); // 为a分配CPU和GPU空间
  init(a, N); // 为数组a赋值
  size_t threads_per_block = 256; // 定义每个block的thread数量
  size_t number_of_blocks = (N + threads_per_block - 1) / threads_per_block; // 定义block的数量
  doubleElements<<<number_of_blocks, threads_per_block>>>(a, N); // 执行核函数
  cudaDeviceSynchronize(); // 同步
  bool areDoubled = checkElementsAreDoubled(a, N); // 检查元素是否为复数
  printf("All elements were doubled? %s\n", areDoubled ? "TRUE" : "FALSE");
  cudaFree(a); // 释放由cudaMallocManaged
}


将上述代码命名为double-elements.cu,然后编译运行:


nvcc double-elements.cu -o double-elements
./double-elements


输出:


0a2653c851af460fa595bd959398a8f1.png


5. 网格大小与实际并行工作量不匹配


2019082413041482.gif


5.1. 网格大于工作量


鉴于 GPU 的硬件特性,线程块中的线程数最好配置为 32 的倍数。但是在实际工作中,很可能会出现这样的情况,我们手动配置参数所创建的线程数无法匹配为实现并行循环所需的线程数,比如实际上需要执行1230次循环,但是你却配置了2048个线程。


我们不可能每次配置参数的时候都手动去算一遍最佳配置,更何况并不是所有的数都是 32 的倍数。不过这个问题现在已经可以通过以下三个步骤轻松地解决:


首先,设置配置参数,使线程总数超过实际工作所需的线程数。

然后,在向核函数传递参数时传递一个用于表示要处理的数据集总大小或完成工作所需的总线程数 N。

最后,计算网格内的线程索引后(使用 threadIdx + blockIdx*blockDim),判断该索引是否超过 N,只在不超过的情况下执行与核函数相关的工作。

以下是一种可选的配置方式,适用于 工作总量 N 和线程块中的线程数已知的情况。如此一来,便可确保网格中至少始终能执行 N 次任务,且最多只浪费 1 个线程块的线程数量:


// 假设N是已知的
int N = 100000;
// 把每个block中的thread数设为256
size_t threads_per_block = 256;
// 根据N和thread数量配置Block数量
size_t number_of_blocks = (N + threads_per_block - 1) / threads_per_block;
some_kernel<<<number_of_blocks, threads_per_block>>>(N);


由于上述执行配置致使网格中的线程数超过 N,因此需要注意 some_kernel 定义中的内容,以确保 some_kernel 在由其中一个额外的(大于N的)线程执行时不会尝试访问超出范围的数据元素,也就是要加个判断:


__global__ some_kernel(int N) {
  int idx = threadIdx.x + blockIdx.x * blockDim.x;
  if (idx < N) { // 保证线程ID小于元素数量N
    // 并行代码
  }


使用不匹配的配置参数来加速 For 循环


#include <stdio.h>
__global__ void initializeElementsTo(int initialValue, int *a, int N) {
  int i = threadIdx.x + blockIdx.x * blockDim.x;
  if (i < N) {
    a[i] = initialValue;
  }
}
int main() {
  int N = 1000;
  int *a;
  size_t size = N * sizeof(int);
  cudaMallocManaged(&a, size);
  size_t threads_per_block = 256;
  // 这是惯用的CUDA语法
  // 为 number_of_blocks 分配一个值,以确保线程数至少与指针 a 中可供访问的元素数同样多。
  size_t number_of_blocks = (N + threads_per_block - 1) / threads_per_block;
  int initialValue = 6; // 初始化的值
  initializeElementsTo<<<number_of_blocks, threads_per_block>>>(initialValue, a, N);
  cudaDeviceSynchronize();
  // 检查元素值是否被初始化
  for (int i = 0; i < N; ++i) {
    if(a[i] != initialValue) {
      printf("FAILURE: target value: %d\t a[%d]: %d\n", initialValue, i, a[i]);
      exit(1);
    }
  }
  printf("SUCCESS!\n");
  cudaFree(a);
}


将上述代码命名为mismatched-config-loop.cu,然后编译运行:


nvcc mismatched-config-loop.cu -o mismatched-config-loop
./mismatched-config-loop


输出:


0a2653c851af460fa595bd959398a8f1.png


5.2. 网格小于工作量


2019082413041482.gif

有时,工作量比网格大,或者出于某种原因,一个网格中的线程数量可能会小于实际工作量的大小。请思考一下包含 1000 个元素的数组和包含 250 个线程的网格(此处使用极小的规模以便于说明)。此网格中的每个线程将需使用 4 次。如要实现此操作,一种常用方法便是在核函数中使用跨网格循环。


在跨网格循环中,每个线程将在网格内使用 threadIdx + blockIdx*blockDim 计算自身唯一的索引,并对数组内该索引的元素执行相应运算,然后用网格中的线程数加上自身索引值,并重复此操作,直至超出数组范围。


例如,对于包含 500 个元素的数组 a 和包含 250 个线程的网格,网格中索引为 20 的线程将执行如下操作:


对 a[20] 执行相应运算;

将线程索引增加 250,使网格的大小达到 270

对a[270] 执行相应运算;

将线程索引增加 250,使网格的大小达到 520

由于 520 现已超出数组范围,因此线程将停止工作。

CUDA 提供一个记录了网格中线程块数的变量:gridDim.x。然后可以利用它来计算网格中的总线程数,即网格中的线程块数乘以每个线程块中的线程数:gridDim.x * blockDim.x。现在来看看以下核函数中网格跨度循环的示例:


__global void kernel(int *a, int N)
{
  int indexWithinTheGrid = threadIdx.x + blockIdx.x * blockDim.x;
  int gridStride = gridDim.x * blockDim.x; // grid 的一个跨步
  for (int i = indexWithinTheGrid; i < N; i += gridStride) {
    // 对 a[i] 的操作;
  }
}


上面是一个简单的例子,现在我们来看看一个更详细的例子,使用了跨网格循环来处理比网格更大的数组:


#include <stdio.h>
// 初始化数组a
void init(int *a, int N) {
  int i;
  for (i = 0; i < N; ++i) {
    a[i] = i;
  }
}
__global__ void doubleElements(int *a, int N) {
  // 使用grid-stride循环,这样每个线程可以处理数组中的多个元素。
  int idx = blockIdx.x * blockDim.x + threadIdx.x;
  int stride = gridDim.x * blockDim.x; // grid 的一个跨步
  for (int i = idx; i < N; i += stride) {
    a[i] *= 2;
  }
}
// 检查数组内所有元素的值是否均为复数
bool checkElementsAreDoubled(int *a, int N) {
  int i;
  for (i = 0; i < N; ++i) {
    if (a[i] != i*2) return false;
  }
  return true;
}
int main() {
  int N = 10000;
  int *a;
  size_t size = N * sizeof(int);
  cudaMallocManaged(&a, size);
  init(a, N); // 初始化数组a
  size_t threads_per_block = 256; // 每个block的thread数量
  size_t number_of_blocks = 32; // block数量
  doubleElements<<<number_of_blocks, threads_per_block>>>(a, N);
  cudaDeviceSynchronize();
  bool areDoubled = checkElementsAreDoubled(a, N); 
// 检查数组内所有元素的值是否均为复数
  printf("All elements were doubled? %s\n", areDoubled ? "TRUE" : "FALSE");
  cudaFree(a);
}


将上述代码命名为grid-stride-double.cu,然后编译运行:


nvcc grid-stride-double.cu -o grid-stride-double
./grid-stride-double


输出:


0a2653c851af460fa595bd959398a8f1.png


6. 错误处理


CUDA 函数发生错误时会返回一个类型为 cudaError_t 的变量,该变量可用于检查调用函数时是否发生错误。以下是对调用 cudaMallocManaged 函数执行错误处理的示例:


cudaError_t err;
err = cudaMallocManaged(&a, N)                    // 假设a和N已经被定义
if (err != cudaSuccess) { // `cudaSuccess` 是一个 CUDA 变量.
  printf("Error: %s\n", cudaGetErrorString(err)); // `cudaGetErrorString` 是一个 CUDA 函数.
}


但是,核函数并不会返回类型为 cudaError_t 的值(因为核函数的返回值为void)。为检查执行核函数时是否发生错误(例如配置错误),CUDA 提供了 cudaGetLastError 函数,可以用于检查核函数执行期间发生的错误。


// 这段程序中的核函数会出一个CUDA错误,但是核函数本身无法捕获该错误
someKernel<<<1, -1>>>();  // 线程数不能为-1
cudaError_t err;
err = cudaGetLastError(); // `cudaGetLastError` 会捕获上面代码中的最近的一个错误
if (err != cudaSuccess) {
  printf("Error: %s\n", cudaGetErrorString(err));


另一个要注意的点是,为了捕捉在异步核函数执行期间发生的错误,一定要检查后续同步 CPU 与 GPU 时 API 调用所返回的状态(例如 cudaDeviceSynchronize);如果之前执行的某一个核函数失败了,则将会返回错误。


添加错误处理的示例:


#include <stdio.h>
// 初始化数组a
void init(int *a, int N) {
  int i;
  for (i = 0; i < N; ++i) {
    a[i] = i;
  }
}
// CUDA 核函数 数组元素值乘2
__global__ void doubleElements(int *a, int N) {
  int idx = blockIdx.x * blockDim.x + threadIdx.x;
  int stride = gridDim.x * blockDim.x;
  // for (int i = idx; i < N; i += stride) {
  // 这里出现一个数值越界错误
  for (int i = idx; i < N + stride; i += stride) {
    a[i] *= 2;
  }
}
// 检查数组元素是否均为复数
bool checkElementsAreDoubled(int *a, int N) {
  int i;
  for (i = 0; i < N; ++i) {
    if (a[i] != i*2) return false;
  }
  return true;
}
int main() {
  int N = 10000;
  int *a;
  size_t size = N * sizeof(int);
  cudaMallocManaged(&a, size);
  init(a, N);
  cudaError_t syncErr, asyncErr; // 定义错误处理变量
  // size_t threads_per_block = 1024;
  // 线程数大于1024(前面说过每个block的线程数不能超过1024)
  size_t threads_per_block = 2048;
  size_t number_of_blocks = 32;
  doubleElements<<<number_of_blocks, threads_per_block>>>(a, N); // 执行核函数
  syncErr = cudaGetLastError(); // 捕获核函数执行期间发生的错误
  asyncErr = cudaDeviceSynchronize(); // 同步,并捕获同步期间发生的错误
  // 输出错误 说明:两个错误需分别设置(即每次运行时只保留一个错误)
  if (syncErr != cudaSuccess) printf("Error: %s\n", cudaGetErrorString(syncErr));
  if (asyncErr != cudaSuccess) printf("Error: %s\n", cudaGetErrorString(asyncErr));
  bool areDoubled = checkElementsAreDoubled(a, N); // 验证数组元素值是否均为复数
  printf("All elements were doubled? %s\n", areDoubled ? "TRUE" : "FALSE");
  cudaFree(a);
}


将上述代码命名为add-error-handling.cu,然后编译运行:


nvcc add-error-handling.cu -o add-error-handling
./add-error-handling


输出:


0a2653c851af460fa595bd959398a8f1.png


6.1. 定制一个 CUDA 错误处理宏


创建一个包装 CUDA 函数调用的宏对于检查错误十分有用。以下是一个宏示例,我们可以在其他的 CUDA 代码中随时使用:


#include <stdio.h>
#include <assert.h>
// CUDA 错误处理宏
inline cudaError_t checkCuda(cudaError_t result)
{
  if (result != cudaSuccess) {
    fprintf(stderr, "CUDA Runtime Error: %s\n", cudaGetErrorString(result));
    assert(result == cudaSuccess);
  }
  return result;
}
int main() {
  // checkCuda 宏可以返回 CUDA 函数返回的错误类型`cudaError_t`的值
  checkCuda( cudaDeviceSynchronize() )
}


7. 总结


至此,我们已经完成了我们预期的学习目标:


编写、编译及运行既可调用 CPU 函数也可启动GPU核函数的 C/C++ 程序。

使用执行配置控制并行线程层次结构。

重构串行循环以在 GPU 上并行执行其迭代。

分配和释放可用于 CPU 和 GPU 的内存。

处理 CUDA 代码生成的错误。

现在,加速 CPU 应用程序进行是可行的了。


7.1 用 CUDA 实现向量加法


为了展示一下如何综合运用本篇教程提到的内容,我们通过一个向量与向量加分的案例来串用以上知识:


#include <stdio.h>
#include <assert.h>
// CUDA 错误处理宏
inline cudaError_t checkCuda(cudaError_t result)
{
  if (result != cudaSuccess) {
    fprintf(stderr, "CUDA Runtime Error: %s\n", cudaGetErrorString(result));
    assert(result == cudaSuccess);
  }
  return result;
}
// 初始化数组 a
void initWith(float num, float *a, int N) {
  for(int i = 0; i < N; ++i) {
    a[i] = num;
  }
}
// 向量加法核函数
__global__ void addVectorsInto(float *result, float *a, float *b, int N) {
  int index = threadIdx.x + blockIdx.x * blockDim.x;
  int stride = blockDim.x * gridDim.x;
  for(int i = index; i < N; i += stride) {
    result[i] = a[i] + b[i]; // 元素a[i] + 元素 b[i]
  }
}
// 检查 CUDA 向量加分是否计算正确
void checkElementsAre(float target, float *array, int N) {
  for(int i = 0; i < N; i++) {
    if(array[i] != target) {
      printf("FAIL: array[%d] - %0.0f does not equal %0.0f\n", i, array[i], target);
      exit(1);
    }
  }
  printf("SUCCESS! All values added correctly.\n");
}
int main() {
  const int N = 10;
  size_t size = N * sizeof(float);
  float *a;
  float *b;
  float *c;
  // 分配内存,且检查执行期间发生的错误
  checkCuda( cudaMallocManaged(&a, size) );
  checkCuda( cudaMallocManaged(&b, size) );
  checkCuda( cudaMallocManaged(&c, size) );
  initWith(3, a, N); // 将数组a中所有的元素初始化为3
  initWith(4, b, N); // 将数组b中所有的元素初始化为4
  initWith(0, c, N); // 将数组c中所有的元素初始化为0,数组c是结果向量
  // 配置参数
  size_t threadsPerBlock = 256;
  size_t numberOfBlocks = (N + threadsPerBlock - 1) / threadsPerBlock;
  addVectorsInto<<<numberOfBlocks, threadsPerBlock>>>(c, a, b, N); // 执行核函数
  checkCuda( cudaGetLastError() ); // 检查核函数执行期间发生的错误
  checkCuda( cudaDeviceSynchronize() ); // 同步,且检查执行期间发生的错误
  checkElementsAre(7, c, N);  // 检查向量加的结果是否正确
  // 释放内存,且检查执行期间发生的错误
  checkCuda( cudaFree(a) );
  checkCuda( cudaFree(b) );
  checkCuda( cudaFree(c) );
}


7.2. 二维和三维的网格和块


网格和线程块最多可以定义有 3 个维度,使用多个维度定义网格和线程块在处理具有多个维度的数据时可能很有效,例如二维矩阵。如果要定义二维或三维的网格或线程块,可以使用 CUDA 的 dim3 关键字来定义多维网格或块,即如下所示:


dim3 threads_per_block(16, 16, 1);
dim3 number_of_blocks(16, 16, 1);
someKernel<<<number_of_blocks, threads_per_block>>>();


鉴于以上示例,someKernel 内部的变量 gridDim.x、gridDim.y、blockDim.x 和 blockDim.y 均将等于 16。


7.3 用 CUDA 实现矩阵乘法


#include <stdio.h>
#define N  64
// GPU 矩阵乘法
__global__ void matrixMulGPU( int * a, int * b, int * c ) {
  int val = 0;
  int row = blockIdx.x * blockDim.x + threadIdx.x;
  int col = blockIdx.y * blockDim.y + threadIdx.y;
  if (row < N && col < N) {
    for ( int k = 0; k < N; ++k )
      val += a[row * N + k] * b[k * N + col];
    c[row * N + col] = val;
  }
}
// CPU矩阵乘法
void matrixMulCPU( int * a, int * b, int * c ) {
  int val = 0;
  for( int row = 0; row < N; ++row )
    for( int col = 0; col < N; ++col ) {
      val = 0;
      for ( int k = 0; k < N; ++k )
        val += a[row * N + k] * b[k * N + col];
      c[row * N + col] = val;
    }
}
int main() {
  int *a, *b, *c_cpu, *c_gpu;
  int size = N * N * sizeof (int); // Number of bytes of an N x N matrix
  // 分配内存
  cudaMallocManaged (&a, size);
  cudaMallocManaged (&b, size);
  cudaMallocManaged (&c_cpu, size);
  cudaMallocManaged (&c_gpu, size);
  // 初始化数组
  for( int row = 0; row < N; ++row )
    for( int col = 0; col < N; ++col )
    {
      a[row * N + col] = row;
      b[row * N + col] = col + 2;
      c_cpu[row * N + col] = 0;
      c_gpu[row * N + col] = 0;
    }
  dim3 threads_per_block (16, 16, 1); // 一个 16 * 16 的线程阵
  dim3 number_of_blocks ((N / threads_per_block.x) + 1, (N / threads_per_block.y) + 1, 1);
  matrixMulGPU <<< number_of_blocks, threads_per_block >>> ( a, b, c_gpu ); // 执行核函数
  cudaDeviceSynchronize(); // 同步
  matrixMulCPU( a, b, c_cpu ); // 执行 CPU 版本的矩阵乘法
  // 比较 CPU 和 GPU 两种方法的计算结果是否一致
  bool error = false;
  for( int row = 0; row < N && !error; ++row )
    for( int col = 0; col < N && !error; ++col )
      if (c_cpu[row * N + col] != c_gpu[row * N + col]) {
        printf("FOUND ERROR at c[%d][%d]\n", row, col);
        error = true;
        break;
      }
  if (!error)
    printf("Success!\n");
  // 释放内存
  cudaFree(a); cudaFree(b);
  cudaFree( c_cpu ); cudaFree( c_gpu );
}
相关实践学习
部署Stable Diffusion玩转AI绘画(GPU云服务器)
本实验通过在ECS上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。
相关文章
|
5月前
|
C++
C++ 根据程序运行的时间和cpu频率来计算在另外的cpu上运行所花的时间
C++ 根据程序运行的时间和cpu频率来计算在另外的cpu上运行所花的时间
54 0
|
3月前
|
存储 并行计算 安全
C++多线程应用
【10月更文挑战第29天】C++ 中的多线程应用广泛,常见场景包括并行计算、网络编程中的并发服务器和图形用户界面(GUI)应用。通过多线程可以显著提升计算速度和响应能力。示例代码展示了如何使用 `pthread` 库创建和管理线程。注意事项包括数据同步与互斥、线程间通信和线程安全的类设计,以确保程序的正确性和稳定性。
|
3月前
|
存储 程序员 编译器
简述 C、C++程序编译的内存分配情况
在C和C++程序编译过程中,内存被划分为几个区域进行分配:代码区存储常量和执行指令;全局/静态变量区存放全局变量及静态变量;栈区管理函数参数、局部变量等;堆区则用于动态分配内存,由程序员控制释放,共同支撑着程序运行时的数据存储与处理需求。
181 21
|
3月前
|
算法 数据挖掘 Shell
「毅硕|生信教程」 micromamba:mamba的C++实现,超越conda
还在为生信软件的安装配置而烦恼?micromamba(micromamba是mamba包管理器的小型版本,采用C++实现,具有mamba的核心功能,且体积更小,可以脱离conda独立运行,更易于部署)帮你解决!
86 1
|
3月前
|
存储 C++
c++的指针完整教程
本文提供了一个全面的C++指针教程,包括指针的声明与初始化、访问指针指向的值、指针运算、指针与函数的关系、动态内存分配,以及不同类型指针(如一级指针、二级指针、整型指针、字符指针、数组指针、函数指针、成员指针、void指针)的介绍,还提到了不同位数机器上指针大小的差异。
73 1
|
3月前
|
Linux C语言 C++
vsCode远程执行c和c++代码并操控linux服务器完整教程
这篇文章提供了一个完整的教程,介绍如何在Visual Studio Code中配置和使用插件来远程执行C和C++代码,并操控Linux服务器,包括安装VSCode、安装插件、配置插件、配置编译工具、升级glibc和编写代码进行调试的步骤。
428 0
vsCode远程执行c和c++代码并操控linux服务器完整教程
|
3月前
|
存储 编译器 C++
【C++篇】揭开 C++ STL list 容器的神秘面纱:从底层设计到高效应用的全景解析(附源码)
【C++篇】揭开 C++ STL list 容器的神秘面纱:从底层设计到高效应用的全景解析(附源码)
85 2
|
4月前
|
编译器 C++
【C++核心】函数的应用和提高详解
这篇文章详细讲解了C++函数的定义、调用、值传递、常见样式、声明、分文件编写以及函数提高的内容,包括函数默认参数、占位参数、重载等高级用法。
34 3
|
4月前
|
C++
【C++基础】程序流程结构详解
这篇文章详细介绍了C++中程序流程的三种基本结构:顺序结构、选择结构和循环结构,包括if语句、三目运算符、switch语句、while循环、do…while循环、for循环以及跳转语句break、continue和goto的使用和示例。
78 2
|
5月前
|
存储 算法 C++
C++ STL应用宝典:高效处理数据的艺术与实战技巧大揭秘!
【8月更文挑战第22天】C++ STL(标准模板库)是一组高效的数据结构与算法集合,极大提升编程效率与代码可读性。它包括容器、迭代器、算法等组件。例如,统计文本中单词频率可用`std::map`和`std::ifstream`实现;对数据排序及找极值则可通过`std::vector`结合`std::sort`、`std::min/max_element`完成;而快速查找字符串则适合使用`std::set`配合其内置的`find`方法。这些示例展示了STL的强大功能,有助于编写简洁高效的代码。
59 2