0. 学习 CUDA 的目的
- 作为一个算法工程师,平时接触
HPC (High Performance Computing)
的机会并不多,那为什么还要学习CUDA
呢? - 学习
CUDA
的目的不是为了用CUDA
做模型加速,而是从CUDA
角度理解目前较新的大模型设计理念,这些高性能模型是如何从原理上做到又快又好的。 - 例如火出圈的
DeepSeek
系列模型,在模型设计角度做了较多创新,并开源了部分CUDA
代码,对于不了解CUDA
的工程师,很难 get 到算法设计的优雅之处。 - 反观某家大模型基座公司,曾开源某个模型结构,论文中一通自夸,分析理论计算量有多低。但很多人实测发现速度并没有很快,究其原因,实际上是这家公司还用的小模型时代的旧思维,即:一个模型理论计算量低,那就是快。
- 大模型时代不了解硬件,不尊重硬件,在算法创新上不太可能走的远。
1. Hello World
cuda 代码
1 |
|
cuda
的三个函数声明符号:__host__
:主机函数,表示该函数在 CPU 上执行,且只能在 CPU 中调用__device__
:设备函数,表示该函数在 GPU 上执行,且只能在 GPU 中调用__global__
:核函数,表示该函数在 GPU 上执行,且只能在 CPU 中调用
- 其中
__global__
声明的函数类型被称为 核函数,是CUDA
中最重要的函数类型- 核函数通过
<<<grid_dim, block_dim>>>
的方式调用,其中<<<>>>
是cuda
扩展关键字 grid_dim
表示启动的block
数量,block_dim
表示每个block
中的线程数量grid_dim
和block_dim
都是dim3
类型的变量,表示三维数组,如果使用整形则模型y
和z
维度都为 1
- 核函数通过
编译
1 | nvcc hello_world.cu -g -o hello_world |
运行
1 | ./hello_world |
2. dimension 测试
1 |
|
- 执行结果:
1 | ./check_dimension |
3. CUDA 向量加法
代码
1 |
|
cudaOccupancyMaxPotentialBlockSize
函数用于计算最佳块大小max thread per block
是cuda
中的一个限制,表示每个块中最多可以有多少个线程,一般为1024
,当超过这个限制时,CHECK(cudaGetLastError());
会报错
运行结果
1 | ./sum_arrays |
- 可以看出,
cuda
的执行时间远远小于cpu
的执行时间,相差了2553
倍
总体流程
graph TD A[Host 端申请内存] --> B[Host 端初始化输入数据] B --> C[Device 端申请内存] C --> D[拷贝 Host 输入数据到 Device 端] D --> E[Device 端执行核函数] E --> F[拷贝 Device 端输出数据到 Host 端] F --> G[Host 端检查结果] G --> H[释放 Host 端和 Device 端全部内存] B --> J[Host 端执行普通函数] --> G
细节分析
1. cuda
内存分配是怎么做的?
1 | float *a_d; // 空指针 |
- 这里的二级指针应用很巧妙,由于
c++
中的指针是值传递,所以如果是一级指针传入cudaMalloc
函数时,指针a_d
的值不会改变,因此只能将指针的地址转成二级指针传入内存分配函数
2. cuda
内存拷贝是怎么做的?
1 | cudaMemcpy(a_d, a_h, nByte, cudaMemcpyHostToDevice); |
cudaMemcpy
函数的四个参数分别是:dst
:目标地址src
:源地址size
:拷贝的字节数kind
:拷贝的类型,属于cudaMemcpyKind
枚举类型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 */
3. cuda
核函数如何解析线程索引?
1 | int i = blockIdx.x * blockDim.x + threadIdx.x; |
blockIdx
:表示当前块的索引blockDim
:表示当前块的维度(每个块中的线程数)threadIdx
:表示当前线程的索引- 每个线程中计算两个标量的和
- 由于
gridDim * blockDim
可能大于size
,所以需要判断线程索引是否越界
4. 如何计算最佳块大小?
1 | int minGridSize, bestBlockSize; |
cudaOccupancyMaxPotentialBlockSize
函数用于计算最佳块大小,五个参数分别是:minGridSize
:最小网格大小变量地址bestBlockSize
:最佳块大小变量地址kernel
:核函数指针dynamicSMemSize
:动态共享内存大小blockSizeLimit
:块大小限制
- 函数名就是函数地址,可强转为
void *
函数指针(也可以写成:(void *)&sumArraysGPU
)
4. CUDA 编程模型
线程块
- 线程块
block
是CUDA
中的逻辑执行单元,是一个三维逻辑结构:block.x
:表示块的 x 维度大小block.y
:表示块的 y 维度大小block.z
:表示块的 z 维度大小- 其中
block.x
是最内层的循环,block.y
是第二层循环,block.z
是最外层的循环 - 用三维数组可以表示为:
tread[z][y][x]
,即tid = threadIdx.x + threadIdx.y * blockDim.x + threadIdx.z * blockDim.x * blockDim.y
线程束
- 线程束
warp
是CUDA
基本调度执行单元,一个warp
由32
个线程组成- 一个
warp
中的线程在一个时钟周期内执行同一条指令(单指令多线程,SIMT
) - 一个
warp
中的线程可以共享指令指针和执行资源(如寄存器、缓存等) Warp
调度器(warp scheduler
)负责将warp
分配到物理执行单元上执行
- 一个
- 线程块会被划分为多个
warp
,
CUDA core
CUDA core
是CUDA
物理执行单元,负责实际的计算任务- 一个
CUDA core
一个时钟周期只能计算一个线程的指令
StreamMultiprocessor
StreamMultiprocessor
流式多处理器(简称SM
),负责执行CUDA
线程块中的并行计算任务- 每个
GPU
包含多个SM
,每个SM
包含多个CUDA core
,例如:RTX 4060
有24
个SM
,每个SM
有128
个CUDA core
5. Reduce
- 规约(
Reduce
)是CUDA
编程中常见的操作,主要用于将多个数据元素规约为一个数据元素 - 规约操作通常是一个二元操作,例如:
sum
、mul
、max
、min
等,简单的规约可以合并成强大的算子,甚至可以说规约算子是神经网络的基础
规约求和
1 | // CPU 规约求和 |
效率分析
- 本代码中使用了三种核函数实现方式做同一个规约操作,分别是:
reduceNeighbored
:相邻线程规约reduceNeighboredLess
:相邻线程规约(简化版)reduceInterleaved
:交错线程规约
- 三者效率从高到低依次是:
reduceInterleaved
>reduceNeighboredLess
>reduceNeighbored
- 三者的示意图分别如下:
reduceNeighbored
:相邻线程规约的实现
reduceNeighboredLess
:相邻线程规约的简化版实现(注意圆圈中的符号已和上图不一致)
reduceInterleaved
:交错线程规约的实现
- 三者效率差异主要来自于 线程分支分化,后续会详细介绍
6. 循环展开
- 循环展开(
Loop Unrolling
)是CUDA
中常用的优化手段,主要用于减少循环控制开销和提高指令级并行度 - 简单说就是一个线程不再只计算一个数据,而是计算多个数据,而且是直接在代码中展开,而不是在编译器中展开
- 可以简单理解成:启动线程是需要花时间的,启动一个线程只算一个数据,太浪费了,所以我们可以让一个线程计算多个数据,这样就能减少启动线程的时间开销,所以就省时间了
代码
1 | // 数据总长是 8 * blockDim.x * gridDim.x |
7. 核函数递归调用
- 和
CPU
一样,CUDA
也支持核函数递归调用,调用方式和普通递归函数一样 - 需要注意的是在编译的时候需要加上
-rdc=true
选项
代码
1 | __global__ void nesthelloworld(int iSize, int iDepth) { |
8. 固定内存
Pinned Memory
是CUDA
中的一种特殊内存类型(不是显存,是内存),主要用于提高数据传输效率- 普通内存是分页管理,分页管理存在两个问题:
- 一页内存逻辑上连续,但物理上不连续
- 操作系统可能会将内存页交换到磁盘上,导致数据不在物理内存中
Pinned Memory
就是解决了这两个问题,分配了一块连续物理地址且固定的主机内存(host
内存),方便整块拷贝数据到显存(DMA
)
代码
1 |
|
关键点
- 主存 普通内存 的分配和释放函数是
malloc
和free
- 主存 固定内存 的分配和释放函数是
cudaMallocHost
和cudaFreeHost
- 显存 的分配和释放函数是
cudaMalloc
和cudaFree
9. 零拷贝内存 和 统一虚拟地址
Zero-Copy Memory
是CUDA
中一种允许GPU
直接访问主机内存的技术,避免了显式的数据拷贝操作(不需要用cudaMemcpy
函数)- 实际上,
Zero-Copy Memory
在很多时候并不快,因为GPU
访问主机内存的速度远远低于访问显存的速度,因此,Zero-Copy Memory
只适用于一些特殊的场景,例如:- 主机内存中的数据只需要被
GPU
使用一次 - 数据量太大,显存放不下
- 调试用途
- 主机内存中的数据只需要被
Zero-Copy Memory
的实现方式是将主机内存映射到GPU
的地址空间中,GPU
通过访问这个地址空间来访问主机内存,实际上走的是PCIe
总线- 由于不需要先完成所有数据的拷贝再开始执行核函数,因此
Zero-Copy Memory
使用异步拷贝的方式来实现,可将部分拷贝数据的时间和核函数执行的时间重叠,但并不多 Unified Virtual Addressing (UVA)
是CUDA
中的一种内存管理机制,允许CPU
和GPU
共享同一虚拟地址空间
代码
1 | float *a_host, *b_host, *res_d; |
- 和
Zero-Copy Memory
一样,UVA
甚至不需要将a_host
转成a_dev
,直接用a_host
就可以调用核函数