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就可以调用核函数
10. Aos 和 SoA
CUDA不仅支持最简单的原生数据类型,还支持自定义数据类型(struct),例如Aos和SoA等Aos(Array of Structures)和SoA(Structure of Arrays)是两种不同的数据存储方式,这两种方式由于变量的排布方式不同,导致了访问内存的效率差异
Aos
Aos是将多个结构体存储在一个数组中,每个结构体的成员变量是连续存储的- 例如:
1 | struct AoSStruct { |
- 这里的
res是一个 结构体数组,a和b是结构体的成员变量,每个变量都是一个float类型的标量 - 每个结构体的成员变量是连续存储的,即:
a1 b1 a2 b2 a3 b3 ...
SoA
SoA是将多个结构体的成员变量存储在一个数组中,每个成员变量是连续存储的- 例如:
1 | struct SoAStruct { |
- 这里的
res是一个 结构体,a和b是结构体的成员变量,每个变量都是一个float类型的数组 - 每个成员变量的数组是连续存储的,即:
a1 a2 a3 ... b1 b2 b3 ...
11. 行主序和列主序
行主序(Row Major Order)和列主序(Column Major Order)是两种不同的数组存储方式行主序是将数组的每一行存储在连续的内存中,列主序是将数组的每一列存储在连续的内存中- 例如:
1 | int a[3][4] = { |
行主序存储方式是:1 2 3 4 5 6 7 8 9 10 11 12列主序存储方式是:1 5 9 2 6 10 3 7 11 4 8 12- 默认情况下,
C / C++ / CUDA语言是行主序存储方式 - 在行主序存储下,如果按行序访问数组元素,访问效率会更高,因为连续的内存访问会提高缓存命中率,反之如果按列序访问数组元素,访问效率会更低
速度对比
WIP