1. 项目概述:从CPU到GPU,为什么是CUDA?
如果你写过C语言,也接触过一些AI或者高性能计算的项目,大概率会听到一个词:GPU加速。这听起来很酷,但具体怎么用C语言去“驱动”GPU,让它为你干活,而不是仅仅调用某个现成的库,这中间的门道就值得深究了。这个项目,就是一次从零开始的探索,目标是用最基础的C语言,结合NVIDIA的CUDA平台,亲手实现一个矩阵乘法,并在这个过程中,理解GPU到底是如何在AI计算中扮演“加速器”角色的。
很多人对CUDA的印象停留在“深度学习框架的后端”,比如PyTorch、TensorFlow安装时要选CUDA版本。这没错,但CUDA的本质是一套允许你用C/C++(以及Fortran、Python等)直接编写在GPU上运行代码的并行计算平台和编程模型。你可以把它想象成给GPU这个“超级多核处理器”写驱动指令的说明书和工具箱。AI计算,尤其是神经网络训练和推理,核心是海量的矩阵和张量运算,这些运算天然具有“数据并行”的特性——成千上万个相同或相似的计算可以同时进行。CPU虽然核心强大,但数量有限(几个到几十个),擅长处理复杂的逻辑分支;而GPU则拥有成千上万个为并行计算优化的、相对简单的核心,就像一支庞大的军队,虽然单个士兵(核心)能力不如CPU的特种兵,但“人海战术”在执行大规模简单重复任务时效率碾压。
所以,当我们谈论“用C语言实现CUDA矩阵乘法优化”时,我们实际上是在做一件非常底层和核心的事情:绕过高级框架,直接指挥GPU的千军万马,去完成AI计算中最基础的砖石——矩阵乘法。这不仅有助于你深刻理解AI框架底层的工作原理,更能让你在面对性能瓶颈时,有能力进行定制化的极致优化。接下来,我会带你一步步拆解这个过程,从环境搭建到核心概念,再到代码实现和性能调优,最后分享一些只有踩过坑才知道的实战经验。
2. 环境准备与CUDA核心概念解析
2.1 开发环境搭建:不只是安装驱动
工欲善其事,必先利其器。CUDA开发环境的搭建是第一步,也是最容易踩坑的一步。很多人卡在驱动版本、CUDA Toolkit版本和深度学习框架版本不匹配的问题上。我们的目标是纯粹的C/C++ CUDA开发,所以可以暂时抛开PyTorch等框架,让环境更干净。
首先,你需要一块NVIDIA的GPU。查看显卡型号和支持的CUDA版本是第一步。在Linux下可以用nvidia-smi命令,在Windows下可以通过NVIDIA控制面板查看。这个命令输出的右上角会显示“CUDA Version”,这是你的驱动支持的最高CUDA运行时版本。记住,这是“支持的最高版本”,不代表你已经安装了CUDA Toolkit。
注意:这里有一个关键区分:NVIDIA显卡驱动和CUDA Toolkit是两个不同的东西。驱动让系统能识别和使用GPU,而CUDA Toolkit是包含编译器(nvcc)、库文件、头文件的软件开发包。你的驱动版本决定了你能用的CUDA Toolkit的最高版本。例如,驱动版本为545.xx可能支持最高CUDA 12.3。你可以安装低于或等于此版本的任何CUDA Toolkit。
对于新手,我强烈推荐使用NVIDIA官方提供的**网络安装包(runfile)**进行安装,虽然步骤稍多,但可控性最强,能清晰地知道每个组件安装在哪里。以Ubuntu系统为例,大致流程如下:
- 卸载旧版本:如果之前有安装,先彻底清除旧版本的CUDA和NVIDIA驱动(谨慎操作)。
- 预安装检查:检查系统是否有GPU(
lspci | grep -i nvidia),检查GCC等编译工具链,检查内核头文件。 - 禁用nouveau驱动:这是Linux自带的开源NVIDIA驱动,会和官方驱动冲突,必须禁用。
- 下载并运行runfile:从NVIDIA官网下载对应你系统版本的CUDA Toolkit runfile安装包。执行
sudo sh cuda_<version>_linux.run。在安装选项中,切记不要勾选安装驱动,除非你确定要更新驱动。我们只安装CUDA Toolkit。 - 配置环境变量:安装完成后,将CUDA的二进制文件和库文件路径加入系统的环境变量。
通常这些行会被添加到export PATH=/usr/local/cuda-12/bin:$PATH export LD_LIBRARY_PATH=/usr/local/cuda-12/lib64:$LD_LIBRARY_PATH~/.bashrc文件中以便永久生效。
安装完成后,验证一下:运行nvcc --version应该能输出CUDA编译器版本,运行./deviceQuery(CUDA Samples里的一个程序)应该能详细列出你的GPU信息。
2.2 CUDA编程模型核心:线程层次结构
这是理解CUDA编程最核心、也最需要转变思维的地方。在传统的C语言CPU编程中,我们思考的是“顺序执行”。在CUDA中,我们思考的是“大规模并行执行”。CUDA用了一个非常巧妙的线程层次结构来组织这成千上万的并行任务。
想象你要处理一张超大的图片(比如一个巨大的矩阵)。在CPU上,你可能用两层循环逐像素处理。在GPU上,CUDA让你可以“一键启动”成千上万个线程,每个线程处理一个像素(或矩阵中的一个元素)。为了高效管理这些线程,CUDA将它们分组:
- 线程(Thread):最基本的执行单元。每个线程都独立运行你写的核函数(Kernel)代码,拥有自己的局部变量和寄存器。
- 线程块(Block):一组线程的集合。一个块内的线程可以通过共享内存(Shared Memory)进行高速通信和协作,并且可以同步。这是GPU并行编程中实现数据复用和协作的关键层级。
- 线程网格(Grid):所有线程块的集合。一个内核(Kernel)启动时,就定义了一个网格。
当你启动一个核函数时,你需要指定这个网格的维度,即有多少个块,以及每个块里有多少个线程。语法上看起来像这样:
// 假设我们启动一个包含 (NxN) 个线程的核函数 // 我们决定用 (16x16) 的线程块,那么就需要 (N/16 x N/16) 个块(假设N能被16整除) dim3 blockDim(16, 16); // 每个块有16x16=256个线程 dim3 gridDim((N + blockDim.x - 1) / blockDim.x, (N + blockDim.y - 1) / blockDim.y); // 计算需要的块数,向上取整 myKernel<<<gridDim, blockDim>>>(...); // 启动核函数在核函数内部,每个线程可以通过内置变量blockIdx(块索引)、threadIdx(线程索引)以及blockDim(块维度)来唯一确定自己的“身份”,从而决定自己应该处理哪一部分数据。例如,计算一个二维矩阵中自己对应的全局行号row和列号col:
int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x;这个从“线程索引”到“数据索引”的映射关系,是CUDA编程的基本功。理解不透彻,后面优化就无从谈起。
2.3 GPU内存模型:性能优化的战场
CPU和GPU有各自独立的内存(DRAM)。CPU内存我们很熟悉(Host Memory),GPU内存我们称为设备内存(Device Memory)。数据要在GPU上计算,必须先从主机内存拷贝到设备内存。这个拷贝操作(通过cudaMemcpy)是昂贵的,是优化时需要重点考虑的开销。
更重要的是GPU设备内部的内存层次结构,它直接决定了你程序的性能上限,从慢到快,容量从小到大:
- 全局内存(Global Memory):容量最大(几GB到几十GB),所有线程都可以访问,但延迟高,带宽是瓶颈。相当于CPU的主内存。我们的输入矩阵和输出矩阵通常就放在这里。
- 常量内存(Constant Memory):只读,容量小(64KB),但针对所有线程同时读取同一数据做了特殊优化,有缓存。
- 纹理内存(Texture Memory):专为具有空间局部性的图形纹理读取设计,也有缓存,在某些特定的访问模式下能提升性能。
- 共享内存(Shared Memory):这是块内线程的“高速缓存”。每个线程块有一块共享内存(通常几十KB),块内所有线程可读写,速度比全局内存快上百倍。它是实现核函数内部数据复用、减少全局内存访问的关键。
- 寄存器(Registers):速度最快,每个线程私有。用于存储局部变量。寄存器资源是有限的,使用过多会导致活动线程数减少,影响并行度。
一个高效的CUDA程序,其目标就是:最大化计算强度(计算操作/内存访问比),并尽可能地将数据保存在更快的内存层次(共享内存、寄存器)中,减少对最慢的全局内存的访问次数和延迟。矩阵乘法优化,本质上就是围绕这个目标展开的一场“内存搬运艺术”。
3. 基础实现:从CPU版本到Naive CUDA版本
3.1 CPU上的三重循环矩阵乘法
我们首先用最经典的C语言实现一个CPU版本的矩阵乘法,作为基准和理解的起点。假设我们要计算 C = A * B,其中A是 MxK 矩阵,B是 KxN 矩阵,C是 MxN 矩阵。
void matrixMulCPU(float* A, float* B, float* C, int M, int N, int K) { for (int i = 0; i < M; ++i) { for (int j = 0; j < N; ++j) { float sum = 0.0f; for (int k = 0; k < K; ++k) { sum += A[i * K + k] * B[k * N + j]; // 行主序访问 } C[i * N + j] = sum; } } }这个算法的时间复杂度是 O(MNK)。对于大型矩阵(比如1024x1024),在CPU上运行会非常慢,因为它是完全串行的,并且内存访问模式(特别是对B矩阵的访问)可能不是最缓存友好的。
3.2 第一个CUDA核函数:每个线程计算一个C元素
最直观的CUDA并行化思路是:让一个GPU线程负责计算输出矩阵C中的一个元素。这样我们就需要启动 M * N 个线程。每个线程的工作就是完成上面CPU代码中内层j循环和k循环的工作:累加A的一行和B的一列的点积。
核函数 (kernel) 的编写有一些固定规则:用__global__修饰符声明,返回类型必须是void。下面是这个“朴素”(Naive)版本的实现:
__global__ void matrixMulNaive(float* A, float* B, float* C, int M, int N, int K) { // 计算当前线程对应的输出矩阵C的行列索引 int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x; // 检查索引是否越界 if (row < M && col < N) { float sum = 0.0f; for (int k = 0; k < K; ++k) { // A的第row行,第k列元素 // B的第k行,第col列元素 sum += A[row * K + k] * B[k * N + col]; } C[row * N + col] = sum; } }在主函数(Host Code)中,我们需要做以下几件事:
- 分配设备内存:使用
cudaMalloc为矩阵A、B、C在GPU上分配空间。 - 拷贝数据:使用
cudaMemcpy将矩阵A和B的数据从主机内存拷贝到设备内存。 - 配置并启动核函数:计算网格和块的维度,然后调用
matrixMulNaive<<<gridDim, blockDim>>>(d_A, d_B, d_C, M, N, K)。 - 拷贝回结果:将计算好的设备内存中的C矩阵拷贝回主机内存。
- 释放设备内存:使用
cudaFree。
这个版本已经实现了并行计算,对于大矩阵,其速度会比CPU单线程版本快很多。但是,它的性能非常差,是后续所有优化版本的“垫脚石”。性能瓶颈主要在于:对全局内存的访问效率极低。每个线程为了计算一个C元素,需要读取A的一整行和B的一整列。这些读取操作是全局内存的访问,而且对B矩阵的访问是跨步的(strided),无法合并(Coalesced),导致内存带宽利用率极低。接下来,我们就针对这些瓶颈进行优化。
4. 核心优化策略一:利用共享内存与线程块分块
4.1 优化思路:分块计算与数据复用
在朴素版本中,每个线程独立地从全局内存读取A的一行和B的一列。仔细观察计算过程,你会发现数据被大量重复读取。例如,计算C矩阵第一行各个元素时,都需要用到A矩阵的第一行数据;计算C矩阵第一列各个元素时,都需要用到B矩阵的第一列数据。
共享内存的引入就是为了解决这个问题。思路是:将一个线程块需要的数据“块”从全局内存一次性加载到共享内存中,然后块内的所有线程都从共享内存中快速读取数据,进行协作计算。这被称为“分块”(Tiling)矩阵乘法。
我们定义一个块的大小为TILE_WIDTH x TILE_WIDTH(例如16x16)。这个块负责计算输出矩阵C中一个TILE_WIDTH x TILE_WIDTH大小的子块。为了计算这个子块,它需要A矩阵中对应的TILE_WIDTH行数据,和B矩阵中对应的TILE_WIDTH列数据。由于K维度可能很大,我们无法一次性将整行整列放入共享内存(容量有限)。因此,我们沿K维度进行“分阶段”计算:
- 将K维度分成若干段(Phases)。
- 在每个阶段,将A的一个
TILE_WIDTH x TILE_WIDTH子块和B的一个TILE_WIDTH x TILE_WIDTH子块从全局内存加载到共享内存中。 - 线程块内所有线程协作,用共享内存中的这两个小块进行部分矩阵乘积累加。
- 移动到下一个阶段,重复步骤2-3,直到处理完整个K维度。
4.2 优化核函数实现详解
下面是基于共享内存分块优化的核函数代码框架。为了清晰,我们假设矩阵维度是TILE_WIDTH的整数倍。
__global__ void matrixMulShared(float* A, float* B, float* C, int M, int N, int K) { // 声明共享内存,用于存储A和B的一个Tile __shared__ float s_A[TILE_WIDTH][TILE_WIDTH]; __shared__ float s_B[TILE_WIDTH][TILE_WIDTH]; // 计算当前线程在块内的局部索引和块在网格中的索引 int bx = blockIdx.x, by = blockIdx.y; int tx = threadIdx.x, ty = threadIdx.y; // 计算当前线程负责的C矩阵中的元素行号与列号 int row = by * TILE_WIDTH + ty; int col = bx * TILE_WIDTH + tx; float sum = 0.0f; // 沿K维度循环,分阶段计算 for (int ph = 0; ph < (K + TILE_WIDTH - 1) / TILE_WIDTH; ++ph) { // 协作加载A的Tile到共享内存 // 每个线程负责加载一个元素 int loadRow_A = row; int loadCol_A = ph * TILE_WIDTH + tx; // 沿K维度移动 if (loadRow_A < M && loadCol_A < K) { s_A[ty][tx] = A[loadRow_A * K + loadCol_A]; } else { s_A[ty][tx] = 0.0f; // 处理边界 } // 协作加载B的Tile到共享内存 int loadRow_B = ph * TILE_WIDTH + ty; int loadCol_B = col; if (loadRow_B < K && loadCol_B < N) { s_B[ty][tx] = B[loadRow_B * N + loadCol_B]; } else { s_B[ty][tx] = 0.0f; } // 等待块内所有线程完成共享内存的加载 __syncthreads(); // 使用共享内存中的数据进行计算 for (int k = 0; k < TILE_WIDTH; ++k) { sum += s_A[ty][k] * s_B[k][tx]; } // 等待块内所有线程完成计算,确保下一阶段加载前,共享内存中的数据不再被使用 __syncthreads(); } // 将最终结果写回全局内存C if (row < M && col < N) { C[row * N + col] = sum; } }4.3 性能提升原理与注意事项
这个版本的性能提升是巨大的,主要原因有三点:
- 全局内存访问合并:在加载Tile时,
s_A[ty][tx]的加载操作中,一个Warp(32个连续线程)的线程tx是连续的,它们访问的全局内存地址A[loadRow_A * K + ph*TILE_WIDTH + tx]也是连续的。这符合合并内存访问的条件,GPU可以一次事务读取一大段连续内存,极大提高了全局内存带宽利用率。B的加载同理。 - 数据复用:加载到共享内存的A Tile和B Tile被块内所有线程重复使用
TILE_WIDTH次(内层k循环),这相当于将全局内存访问量减少了约TILE_WIDTH倍。 - 高速共享内存:后续的累加计算全部在共享内存中进行,速度比全局内存快几个数量级。
实操心得:TILE_WIDTH的选择与资源限制
TILE_WIDTH的选择不是越大越好。它受限于两个硬件限制:
- 共享内存大小:每个线程块可用的共享内存有限(例如48KB)。
TILE_WIDTH=32时,s_A和s_B各需要32*32*4B=4KB,总共8KB,一个流多处理器(SM)可以容纳多个这样的线程块。- 寄存器数量与线程数:
TILE_WIDTH决定了每个块的线程数(TILE_WIDTH * TILE_WIDTH)。块内线程数过多,每个线程可用的寄存器可能减少,或者导致SM上能同时驻留的块数减少,影响Occupancy(占用率,即活跃线程束的比例)。通常16x16(256线程)或32x32(1024线程,是上限)是常见选择。需要通过性能分析工具(如nvprof或 Nsight Compute)来权衡。
5. 核心优化策略二:进一步优化——寄存器使用、循环展开与向量化
在共享内存分块的基础上,我们还可以进行更深层次的优化,这些优化通常需要更精细地控制CUDA编程细节。
5.1 使用寄存器存储累加和与循环展开
在上面的共享内存版本中,每个线程的累加和sum存储在寄存器中。我们可以通过循环展开来进一步减少循环开销和增加指令级并行。
内层的for (int k = 0; k < TILE_WIDTH; ++k)循环次数是固定的(由TILE_WIDTH决定)。编译器有时会自动进行一定程度的循环展开,但我们可以手动展开以获得更确定的优化。例如,如果TILE_WIDTH=16,我们可以写成:
// 手动展开循环 sum += s_A[ty][0] * s_B[0][tx]; sum += s_A[ty][1] * s_B[1][tx]; // ... 省略中间14行 ... sum += s_A[ty][15] * s_B[15][tx];更高级的技巧是使用寄存器缓存。一个线程可以一次从共享内存中加载多个数据到寄存器中,然后进行多次乘加运算,减少对共享内存的访问次数。例如,让一个线程负责计算输出矩阵C中一个小的2x2子块,这样它就需要加载A的2行和B的2列到寄存器,然后进行4次点积运算。这增加了每个线程的计算量(提高了计算强度),减少了线程总数(可能影响占用率),但能更好地隐藏内存延迟,在特定情况下非常有效。
5.2 利用向量化内存操作
现代GPU支持向量化加载/存储指令,例如一次读取或写入float2、float4类型的数据。这可以进一步提高内存带宽的利用率。在满足对齐要求的前提下,我们可以修改数据加载逻辑。例如,假设TILE_WIDTH是4的倍数,并且内存地址是对齐的,我们可以用float4来加载数据:
// 假设我们确保共享内存数组s_A是按float4对齐的 float4* s_A_vec4 = (float4*)s_A; float4 load_vec = s_A_vec4[ty * (TILE_WIDTH/4) + tx/4]; // 需要重新计算索引映射 // 然后从load_vec.x, .y, .z, .w中获取四个float值这要求更精细的线程索引映射和数据布局设计,但能带来显著的带宽提升。
5.3 核函数启动配置的优化
核函数的性能与启动配置<<<gridDim, blockDim>>>密切相关。除了之前提到的blockDim(线程块大小)选择,gridDim(网格大小)也需要合理设置。
- 网格大小应足够大:以充分利用GPU上所有的SM。通常网格中的线程块数量应该是SM数量的若干倍,以保持GPU始终处于忙碌状态,隐藏不同线程块执行时的延迟。
- 动态并行:对于更复杂的问题,CUDA支持在核函数内部启动新的核函数(动态并行),但这会增加复杂度,通常用于解决不规则或递归问题,在规则的矩阵乘法中一般不使用。
优化的目标是在占用率、寄存器压力、共享内存使用之间取得平衡。可以使用CUDA提供的Occupancy Calculator电子表格或nvcc编译器的--ptxas-options=-v选项来查看寄存器和共享内存的使用情况,从而调整线程块大小和资源使用。
6. 高级主题:使用CUDA库与性能分析
6.1 调用cuBLAS库:性能天花板
经过上述一系列优化,你的自定义核函数可能已经非常快了。但要知道,NVIDIA官方提供的CUDA基础线性代数子程序库——cuBLAS,其矩阵乘法(cublasSgemm)是经过NVIDIA工程师极致优化的,融合了所有已知的高级技巧(包括汇编级别的优化、对特定硬件架构的调优等),代表了当前GPU上矩阵乘法的性能天花板。
在你的C程序中调用cuBLAS非常简单:
- 链接
cublas库。 - 包含
cublas_v2.h头文件。 - 创建cuBLAS句柄 (
cublasCreate)。 - 调用
cublasSgemm函数,指定矩阵的运算(转置与否)、维度、精度等参数。 - 销毁句柄 (
cublasDestroy)。
将你的优化版本与cuBLAS的性能进行对比,是一个衡量你优化成果的绝佳基准。通常,一个优秀的自定义实现能达到cuBLAS性能的70%-90%就已经非常出色了。
6.2 性能分析与调试工具
优化离不开测量和分析。CUDA提供了强大的工具链:
- nvprof / Nsight Systems:时间线分析器。可以查看核函数执行时间、内存拷贝时间、API调用时间线,帮助你定位是计算瓶颈还是内存瓶颈,以及核函数的执行效率。
- Nsight Compute:核函数性能分析器。这是更强大的工具,可以深入分析核函数的细节:占用率、内存吞吐量、缓存命中率、指令发射效率、分支分化等。它会给出具体的性能指标和建议,是进行微观优化的必备利器。
- cuda-memcheck:内存错误检查工具。用于检查越界访问、未初始化内存使用等错误。
- printf调试:在核函数中可以使用
printf,但输出会在所有线程执行完后才显示,且可能影响性能,仅用于调试。
一个典型的优化流程是:编写基础版本 -> 用nvprof分析热点 -> 应用共享内存优化 -> 用Nsight Compute分析瓶颈(如共享内存bank冲突、指令发射停顿) -> 进行寄存器优化/循环展开 -> 对比cuBLAS性能。
7. 实战避坑指南与经验总结
7.1 常见问题与解决方案速查表
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
CUDA error: no kernel image is available for execution | 编译的核函数二进制代码与当前GPU的架构不匹配。 | 检查GPU计算能力(如sm_75 for Turing)。在编译时使用-arch=sm_xx指定正确的架构。对于需要兼容多种显卡的情况,可以使用-arch=compute_xx -code=sm_xx,sm_yy生成胖二进制(Fatbin)。 |
CUDA error: invalid configuration argument | 核函数启动配置<<<grid, block>>>参数非法。 | 检查线程块大小(block.x * block.y * block.z)是否超过硬件限制(通常是1024)。检查网格维度是否过大导致溢出。检查共享内存申请是否超过每块限制。 |
| 程序运行结果不正确(NaN或错误值) | 1. 线程索引计算错误,导致数组越界。 2. 共享内存未初始化或同步错误。 3. 整数除法/类型转换问题。 | 1. 在核函数开始处添加边界检查if (row >= M || col >= N) return;。2. 确保每个 __syncthreads()使用正确,所有线程都到达后才进行下一步。3. 使用 cuda-memcheck检查内存错误。在主机代码中使用cudaDeviceSynchronize()和检查cudaPeekAtLastError()/cudaGetLastError()。 |
| 性能远低于预期 | 1. 全局内存访问未合并。 2. 共享内存Bank冲突。 3. 寄存器溢出(Spill)到本地内存。 4. 线程块大小选择不当,占用率低。 | 1. 使用Nsight Compute分析全局内存访问效率,确保相邻线程访问连续地址。 2. 分析共享内存访问模式,避免多个线程同时访问同一个bank的不同地址(bank conflict)。可以通过改变数据在共享内存中的布局(如使用padding)来缓解。 3. 查看编译器报告的寄存器使用量,尝试减少核函数中局部变量的使用,或使用 -maxrregcount编译器选项限制寄存器使用以提高占用率(但可能增加寄存器溢出)。4. 使用CUDA Occupancy Calculator调整线程块大小。 |
| 内存拷贝耗时占比高 | 矩阵规模较小,计算强度低,内存拷贝开销相对显著。 | 对于小矩阵,考虑在GPU上完成所有数据生成和连续计算,减少主机与设备间的数据传输次数。或者使用CUDA流(Streams)实现计算与传输的重叠。 |
7.2 关键经验与心得
- 从简到繁,验证每一步:不要一开始就写复杂的优化版本。先从能正确运行的朴素版本开始,然后逐步添加优化(如共享内存),每步都进行正确性验证(与CPU结果对比)和性能测试。这样当出现错误时,更容易定位。
- 理解硬件是优化的基础:花时间了解你的GPU架构(如Turing, Ampere, Ada Lovelace),了解SM的结构、内存层次、Warp调度机制。不同的架构可能有不同的优化侧重点(如Tensor Core)。
- 优化是权衡的艺术:没有银弹。增加共享内存使用可能会降低占用率;展开循环可能增加寄存器压力;让每个线程计算更多元素(提高计算强度)会减少线程总数。你需要使用分析工具,找到针对你特定问题和硬件的最优平衡点。
- 善用官方库和工具:在投入大量时间进行底层优化前,先看看cuBLAS、cuDNN等官方库是否能满足需求。它们经过了极端优化,性能往往是最好的。Nsight Compute是你的“性能显微镜”,一定要学会使用。
- 注意计算精度:GPU进行大量浮点运算时,累加顺序的不同可能导致结果与CPU有细微差异。对于科学计算,需要关注这种非结合性带来的影响。可以使用
-ftz=true(flush denormals to zero)、-prec-div=false、-prec-sqrt=false等编译器选项来提升速度,但会牺牲一些精度。
通过这个从C语言到CUDA,从朴素实现到深度优化的完整旅程,你不仅学会了一个矩阵乘法的实现,更重要的是掌握了GPU并行计算的核心思维方式和一套完整的性能分析与优化方法论。这套方法可以迁移到任何需要GPU加速的计算密集型任务上,无论是AI推理、图像处理还是科学模拟。记住,理解原理比记住代码更重要,动手实践和 profiling 是突破性能瓶颈的唯一途径。