大家都知道AI现在很火,为什么AI需要用显卡来跑呢,NVIDIA为啥赚的盆满钵满。最近我就去稍微研究了一下并行计算。AI计算本质上就是一大堆矩阵运算,而矩阵运算天生就适合使用并行计算进行优化。
CUDA&OpenCL
CUDA(Compute Unified Device Architecture)是NVIDIA推出的并行计算平台和编程模型,允许开发者利用NVIDIA GPU(图形处理器)的强大计算能力加速通用计算任务。它突破了GPU仅用于图形渲染的传统限制,使其能够处理复杂的科学计算、深度学习等任务。
OpenCL(Open Computing Language) 是由 Khronos Group 维护的开放标准,用于跨平台并行计算。它允许开发者利用多种硬件(如CPU、GPU、FPGA等)的并行计算能力,支持异构系统(不同厂商的硬件组合)。
CUDA和OpenCL都可以实现并行计算,CUDA被绑定在了N卡上,而OpenCL生态碎片化很严重,不同的显卡有不同的标准和扩展,很难上手,只能说各有取舍。
我在项目中使用的OpenCL库是ProjectPhysX/OpenCL-Wrapper
个人认为封装的很好,上手也相对比较容易。CUDA直接使用Visual Studio的项目模板就可以了。
并行任务的核心思想:任务拆分
如何进行并行编程呢,一个很重要的问题就是你要实现的功能能否被拆分为更小的任务。类似于Java的ForkJoinPool,本质的思想其实都是将大任务不断拆分为小任务,然后执行小任务,完成后再将结果合并到一起。
并行计算以及并发并不是一个灵丹妙药,用上了就一定会变快。并不是所有的任务都适合并行计算,只有任务的规模很大,每个任务相互独立,并且单个任务执行开销较小的情况下才会有显著提升,和多线程并发的道理相同。否则环境准备和上下文切换以及最后结果合并的开销比任务执行本身还大的话,实际上是减速的。
我在自己的项目中也使用了CUDA来实现并行计算,File-Engine-Core/C++/cudaAccelerator/cudaAccelerator/dllmain.cpp at master · XUANXUQAQ/File-Engine-Core,以及对应的OpenCL版本。
File-Engine-Core/C++/openclAccelerator/src/dllmain.cpp at master · XUANXUQAQ/File-Engine-Core
这里就来简单梳理一下如何使用CUDA来并行进行关键字匹配。
-
准备数据 由于大部分电脑并不是统一内存,所以首先要做的就是将数据加载到显卡中。对应的API在nvidia官网都写的非常的详细。CUDA Runtime API :: CUDA Toolkit Documentation 调用cudaMalloc进行内存分配。传入一个二级指针,和要分配的大小,就能进行内存分配。
cpp__host____device__cudaError_t cudaMalloc ( void** devPtr, size_t size )
这里解释一下前面的__host__
,__device__
,在 CUDA 编程中,__host__
、__device__
和 __global__
是用于修饰函数的关键字,它们定义了函数的执行位置(在 CPU 还是 GPU 上执行)以及调用方式。
修饰符 | 执行位置 | 调用者 | 典型用途 |
---|---|---|---|
__host__ |
CPU | CPU | 主程序逻辑、数据预处理 |
__device__ |
GPU | GPU(核函数/其他设备函数) | GPU 内部的辅助函数 |
__host__ __device__ |
CPU/GPU | CPU 或 GPU | 跨 CPU/GPU 的通用工具函数 |
__global__ |
GPU | CPU(通过 <<<>>> 启动) |
并行计算的核心逻辑,核函数 |
显存分配完成之后就需要把数据从内存拷贝到显存。调用cudaMemcpy进行拷贝。
cpp
__host__cudaError_t cudaMemcpy ( void* dst, const void* src, size_t count, cudaMemcpyKind kind )
调用这个函数就可以把src指向的内存拷贝到dst,count为需要拷贝的内存大小,单位为字节。最后一个参数kind,代表拷贝的类型,有5种类型。
cpp
enum cudaMemcpyKind
CUDA memory copy types
Values
cudaMemcpyHostToHost = 0
Host -> Host
cudaMemcpyHostToDevice = 1
Host -> Device
cudaMemcpyDeviceToHost = 2
Device -> Host
cudaMemcpyDeviceToDevice = 3
Device -> Device
cudaMemcpyDefault = 4
Direction of the transfer is inferred from the pointer values. Requires unified virtual addressing
cudaMemcpyHostToHost就是内存之间的拷贝,类似于标准库中的memcpy。
cudaMemcpyHostToDevice代表从内存拷贝到显存。
cudaMemcpyDeviceToHost代表从显存拷贝到内存。以此类推。
还有一些其他的内存拷贝函数以及内存分配函数,比如cudaMemcpy2D,cudaMemcpy3D等等,其实只是维度上的差别,这里就不过多赘述。
这里就演示一个简单的数列相加。
cpp
const int N = 1000000; // 数组大小
size_t size = N * sizeof(float);
float *h_a, *h_b, *h_c; // 主机(CPU)指针
float *d_a, *d_b, *d_c; // 设备(GPU)指针
// 1. 在主机端分配内存并初始化数据
h_a = (float*)malloc(size);
h_b = (float*)malloc(size);
h_c = (float*)malloc(size);
for (int i = 0; i < N; i++) {
h_a[i] = 1.0f; // 数组a初始化为1.0
h_b[i] = 2.0f; // 数组b初始化为2.0
}
// 2. 在设备端分配内存
cudaMalloc(&d_a, size);
cudaMalloc(&d_b, size);
cudaMalloc(&d_c, size);
// 3. 将数据从主机拷贝到设备
cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, size, cudaMemcpyHostToDevice);
- 编写核函数 有了数据之后就需要定义如何运算。比如上面计算最简单的两个数列相加,我们已经将显存分配好并且拷贝到了GPU设备。接下来就是编写核函数进行运算。 在 CUDA 编程中,核函数(Kernel Function) 是 GPU 并行计算的核心逻辑,它定义了每个线程(Thread)在 GPU 上执行的具体操作。使用
__global__
关键字声明。 简单来说就是GPU执行时,每一个任务都会创建一个线程,每个线程上面都会跑这个相同的核函数。
cpp
// CUDA核函数:每个线程处理一个元素
__global__ void vectorAdd(const float* a, const float* b, float* c, int n) {
// 计算全局线程索引
int i = blockIdx.x * blockDim.x + threadIdx.x;
// 确保索引不越界
if (i < n) {
c[i] = a[i] + b[i];
}
}
可以看到这个vectorAdd只进行了一个最简单的事,也就是两个数列相加,c[i] = a[i] + b[i]
。上面的int i则为当前的线程id,每一个线程有一个id,通过这个id就可以同时取出a[i]
和b[i]
每个下标的数据,然后同时赋值到对应的c[i]
上。
开启线程执行核函数
cpp
// 4. 配置并启动核函数
int threadsPerBlock = 256; // 每个block的线程数
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock; // 计算需要的block数量
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_a, d_b, d_c, N);
通过vectorAdd<<<blocksPerGrid, threadsPerBlock>>>
就可以实现调用核函数,这一段代码将会开启blockPerGrid * threadsPerBlock个线程,每个线程都去执行vectorAdd函数,获取a[线程id]
b[线程id]
,然后相加得到c[线程id]
。
事实上并不需要自己去计算需要多少个block多少个thread,cuda提供了一个函数cudaOccupancyMaxPotentialBlockSize
cpp
template < class T >
__host__cudaError_t cudaOccupancyMaxPotentialBlockSize ( int* minGridSize, int* blockSize, T func, size_t dynamicSMemSize = 0, int blockSizeLimit = 0 ) [inline]
只需要传入gridSize,blockSize,核函数func就可以自动获取上面的blockPerGrid以及threadsPerBlock,然后执行即可。
cpp
int grid_size = 0, block_size = 0;
const auto block_size_status = cudaOccupancyMaxPotentialBlockSize(&grid_size, &block_size, vectorAdd);
-
获取计算结果
最后只需要将结果复制回内存,我们就得到了并行计算的结果。
cpp// 5. 将结果从设备拷贝回主机 cudaMemcpy(h_c, d_c, size, cudaMemcpyDeviceToHost); // 6. 验证结果 bool success = true; for (int i = 0; i < N; i++) { if (fabs(h_c[i] - 3.0f) > 1e-6) { // 预期结果应为3.0(1.0+2.0) success = false; break; } } printf("%s\n", success ? "Result correct!" : "Result wrong!");
以上我们就完成了一整个并行计算的流程,最后附上完整代码。
cpp
#include <stdio.h>
#include <cuda_runtime.h>
// CUDA核函数:每个线程处理一个元素
__global__ void vectorAdd(const float* a, const float* b, float* c, int n) {
// 计算全局线程索引
int i = blockIdx.x * blockDim.x + threadIdx.x;
// 确保索引不越界
if (i < n) {
c[i] = a[i] + b[i];
}
}
int main() {
const int N = 1000000; // 数组大小
size_t size = N * sizeof(float);
float *h_a, *h_b, *h_c; // 主机(CPU)指针
float *d_a, *d_b, *d_c; // 设备(GPU)指针
cu
// 1. 在主机端分配内存并初始化数据
h_a = (float*)malloc(size);
h_b = (float*)malloc(size);
h_c = (float*)malloc(size);
for (int i = 0; i < N; i++) {
h_a[i] = 1.0f; // 数组a初始化为1.0
h_b[i] = 2.0f; // 数组b初始化为2.0
}
// 2. 在设备端分配内存
cudaMalloc(&d_a, size);
cudaMalloc(&d_b, size);
cudaMalloc(&d_c, size);
// 3. 将数据从主机拷贝到设备
cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, size, cudaMemcpyHostToDevice);
// 4. 配置并启动核函数
int threadsPerBlock = 256; // 每个block的线程数
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock; // 计算需要的block数量
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_a, d_b, d_c, N);
// 5. 将结果从设备拷贝回主机
cudaMemcpy(h_c, d_c, size, cudaMemcpyDeviceToHost);
// 6. 验证结果
bool success = true;
for (int i = 0; i < N; i++) {
if (fabs(h_c[i] - 3.0f) > 1e-6) { // 预期结果应为3.0(1.0+2.0)
success = false;
break;
}
}
printf("%s\n", success ? "Result correct!" : "Result wrong!");
// 7. 释放内存
free(h_a);
free(h_b);
free(h_c);
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
return 0;
}