资讯动态

手写CUDA实现CNN:算子优化、环境搭建与性能调优全攻略

发布时间:2026/9/10 7:06:50 来源:尧图企业网站定制
简介这是一份基于CUDA优化的卷积神经网络实现源码包面向深度学习与高性能计算开发者重点解决CNN在图像识别任务中的GPU加速问题。资源共168个文件压缩包约29.95MB涵盖CUDA内核文件.cu/.h/.cpp、Python辅助脚本、CMake构建配置、Shell运行脚本以及bmp/png图像样本与训练日志等便于对照代码理解数据流与并行计算设计。已有421人学习下载。通过研究源码可掌握CUDA线程块与网格划分、共享内存复用、分块策略等关键优化技术并了解如何将卷积、池化、反向传播等核心操作映射到GPU并行架构。包内还包含多种配置文件与中间产物有助于搭建完整编译环境并复现实验适合正在学习CUDA编程或希望将深度学习算法高效移植到GPU平台的开发者参考。1. 为什么在深度学习框架之外还要读一份手写CUDA的CNN源码某天朋友丢给我一个源码包没有 requirements.txt没有 conda 环境里面是一堆 train_*.bmp 和几个 .bin 文件。编译完跑起来才发现这是一份用 CUDA C 从零写的卷积神经网络卷积、池化、激活、反向传播全部手写甚至连 BMP 图片都是自己解析的。第一反应是何必torch.nn.Conv2d 一行搞定。但仔细读下来GPU 上的访存模式、共享内存的使用、线程块尺寸对占用率的影响比框架的 Python 封装清晰得多。这个包适合做 GPU 算子优化、想把计算搬到端侧、或者需要理解 CNN 每个算子计算量的工程师。下面基于这份源码从 CUDA 编程模型映射到 CNN 算子实现最后给出验证和调优的具体方法。2. 线程块与共享内存CNN 算子在 CUDA 上的并行化映射2.1 从像素独立到线程网格CNN 的前向计算中每一个输出特征图的像素都是一次输入区域与卷积核的点积。这些点积之间没有数据依赖所以天然适合 GPU 的 SIMT 执行模型。在 CUDA 里我们用一个线程负责一个输出像素。假设输入是单通道 32x32卷积核 3x3无填充输出是 30x30。最简单的映射是__global__ void conv_forward_simple(const float* input, const float* kernel, float* output, int in_size, int kernel_size, int out_size) { int row blockIdx.y * blockDim.y threadIdx.y; int col blockIdx.x * blockDim.x threadIdx.x; if (row out_size || col out_size) return; float sum 0.0f; for (int kh 0; kh kernel_size; kh) { for (int kw 0; kw kernel_size; kw) { sum input[(row kh) * in_size (col kw)] * kernel[kh * kernel_size kw]; } } output[row * out_size col] sum; }这段代码的逻辑很直观每个线程计算输出坐标 (row, col) 处的卷积结果。循环遍历卷积核的每个位置做乘加。注意这里没有处理多通道只演示单通道。实际使用时blockDim 通常取 (16,16)gridDim 根据输出尺寸向上取整。为什么不用 32x32 的 block因为一个 block 的线程数上限是 102416x16256 在大多数设备上能够获得较高的占用率。而且共享内存和寄存器的压力也会影响 block 大小稍后我们会看到。2.2 内存层次为什么全局内存版本是瓶颈上面的版本虽然能跑但性能很差。原因在于内层循环里每个线程直接访问全局内存读取 input。卷积核在内存中是连续的小数组会命中常量缓存或 L1但输入图像有多行跨步访问同一个输入像素会被相邻线程重复读取。在 GPU 上全局内存的带宽虽然高但延迟也不小重复访问会造成大量浪费。CUDA 提供了三种适合只读数据的存储全局内存、共享内存、常量内存。下表列出主要差异存储类型作用域延迟容量适合场景全局内存grid约 400-800 周期显存大小输入输出数据共享内存block约 20-30 周期通常 48KB/block分块缓存输入常量内存grid取决于缓存命中64KB卷积核、偏置由于卷积核在单次前向中保持不变且尺寸小放入常量内存很合适。而输入图像需要被大量重复读取一个自然的需求是分块载入共享内存。2.3 共享内存分块卷积的经典 tiling 策略假设我们让每个 block 计算 16x16 的输出区域卷积核是 3x3那么需要从全局内存读取的输入区域是 (163-1)x(163-1)18x18。对应的 CUDA 实现片段#define TILE_W 16 #define TILE_H 16 #define K_SIZE 3 __global__ void conv_forward_tiled(const float* input, const float* kernel, float* output, int in_width, int out_width) { __shared__ float tile[TILE_H K_SIZE - 1][TILE_W K_SIZE - 1]; int out_x blockIdx.x * TILE_W threadIdx.x; int out_y blockIdx.y * TILE_H threadIdx.y; // 加载阶段将输入分块及 halo 区域放入共享内存 if (threadIdx.y TILE_H K_SIZE - 1 threadIdx.x TILE_W K_SIZE - 1) { int gx blockIdx.x * TILE_W threadIdx.x - K_SIZE / 2; int gy blockIdx.y * TILE_H threadIdx.y - K_SIZE / 2; if (gx 0 gy 0 gx in_width gy in_width) { tile[threadIdx.y][threadIdx.x] input[gy * in_width gx]; } else { tile[threadIdx.y][threadIdx.x] 0.0f; } } __syncthreads(); // 计算阶段只处理 block 负责的 TILE_W * TILE_H 输出区域 if (threadIdx.y TILE_H threadIdx.x TILE_W out_x out_width out_y out_width) { float sum 0.0f; #pragma unroll for (int kh 0; kh K_SIZE; kh) { for (int kw 0; kw K_SIZE; kw) { sum tile[threadIdx.y kh][threadIdx.x kw] * kernel[kh * K_SIZE kw]; } } output[out_y * out_width out_x] sum; } }这个内核有几个关键点__shared__声明的 tile 数组比输出区域大 kernel-1用于容纳 halo 区域。加载阶段线程数比计算阶段多所以用条件判断让多出来的线程也参与加载。__syncthreads()是必须的否则某个线程还没写入 tile另一个线程就开始读了。边界检查放在加载阶段计算阶段不再检查保证所有需要的输入已经存在共享内存中。参数说明in_width是输入图像宽度假设输入和输出都是单通道。实际上在多通道情况下tile 可以三维或者选择把输入通道循环放在内层。这里没有做填充如果 stride1输出尺寸是 (in_width - K_SIZE 1)。如果原图有 padding则加载时的坐标偏移需要相应加上 pad。实际编码时还可以利用float4向量化访问全局内存。GPU 的全局内存访问宽度是 128 位一次能读 4 个 float。把输入按 float4 对齐后在循环内一次读入 4 个像素然后分别参与卷积能减少指令数。但注意对齐要求起始地址必须是 16 字节的倍数。对于 stride 或 padding 非 4 倍数的图层需要单独处理边界。2.4 分支发散与 Bank Conflict上面的实现里边界判断会造成部分线程不工作但这是加载阶段影响有限。更隐蔽的是共享内存的 bank 冲突。共享内存按 4 字节划分成 32 个 bank不同线程同时访问同一 bank 且地址不同时会串行化。在卷积的 tile 计算中线程 (ty,tx) 访问 tile[tykh][txkw]相邻 tx 访问相邻地址正好分布在不同的 bank没有冲突。但如果我们把 tile 的第一维定义为[tx][ty]这种反过来布局就很容易冲突。所以布局设计优先让第 0 维对应连续的共享内存地址。3. CMake 与 nvcc源码包的环境搭建与构建参数拿到源码包第一件事不是看代码而是把环境跑通。这个包里没有现成的 Makefile而是 CMake 项目因为 CMake 自带的 CMakeDetermineCompilerABI.bin 表明系统先做了 ABI 探测。我们要做的是确认 CUDA Toolkit 已安装并且 CMake 能找到 nvcc。3.1 确认 CUDA Toolkit 与驱动版本在终端执行nvcc --version nvidia-sminvcc --version显示的是 CUDA Toolkit 版本nvidia-smi显示的是驱动支持的 CUDA 版本。需要理解的是驱动版本是向下兼容的驱动 12.4 可以运行 CUDA 12.4 的运行时。所以如果你在机器上装了多个 CUDA 版本切换时只需要调整PATH和LD_LIBRARY_PATH驱动不用动。这也是网上常见“CUDA 多版本安装”的底层逻辑。我一般用update-alternatives管理 nvcc 路径sudo update-alternatives --install /usr/local/cuda cuda /usr/local/cuda-11.8 0 sudo update-alternatives --install /usr/local/cuda cuda /usr/local/cuda-12.2 0 sudo update-alternatives --config cuda对于 Windows 用户直接安装 CUDA Toolkit 后环境变量CUDA_PATH会被自动设置如果你用 WSL2不要装 Windows 版 CUDA而是在 WSL2 内部安装 Linux 版。WSL2 和宿主机共享 GPU 驱动所以只需要在 WSL 里装 Toolkit 即可。判断是否生效就是跑一下cd /usr/local/cuda/samples/1_Utilities/deviceQuery sudo make ./deviceQuery如果看到PASS说明设备可用。找不到 samples 时先检查是否下载了完整的 Toolkit 而不是仅仅 runtime。3.2 CMakeLists.txt 关键配置在 CMake 中启用 CUDA 语言支持很简单但是选错架构会让内核无法加载。这个源码包的 CMakeLists.txt 通常包含类似下面的片段cmake_minimum_required(VERSION 3.18) project(cuda_cnn LANGUAGES CXX CUDA) set(CMAKE_CUDA_STANDARD 14) set(CMAKE_CUDA_STANDARD_REQUIRED ON) enable_language(CUDA) # 推荐做法让 CMake 根据检测到的 GPU 设置架构 set(CMAKE_CUDA_ARCHITECTURES native)CMAKE_CUDA_ARCHITECTURES指定要生成的 SASS机器码架构。native表示用当前编译机器上的 GPU 架构但这会带来一个问题生成的程序拿到别的机器上可能跑不了。如果你需要分发可以指定多个架构set(CMAKE_CUDA_ARCHITECTURES 75 80 86 89)不同架构的数字含义如下表架构编号微架构常见 GPU75TuringRTX 20 系列、GTX 16 系列80AmpereA100、部分 RTX 30 专业卡86AmpereRTX 30 系列GeForce89Ada LovelaceRTX 40 系列注意不是所有架构都向后兼容 SASS低代 GPU 不能执行高代 SASS但可以执行 PTX 然后 JIT。如果你面向几十台不同型号的机器更稳妥的做法是指定 PTX 版本set(CMAKE_CUDA_ARCHITECTURES 75-real;80-real;100-virtual)real生成 SASSvirtual生成 PTX运行时由驱动进行 JIT 编译。代价是启动时多一次编译。3.3 编译链接与常见错误构建命令不复杂mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc)但如果 CMake 报CMake Error: CUDA not found首先确认nvcc在PATH里或者手动指定cmake .. -DCMAKE_CUDA_COMPILER/usr/local/cuda/bin/nvcc运行程序时最常见的报错是torch.acceleratorerror: cuda error: no kernel image is available for executi这个消息在 PyTorch 里很常见但本质是同一个原因编译内核时指定的 SM 架构与当前 GPU 不匹配。在纯 CUDA 程序里错误信息是CUDA_ERROR_NO_KERNEL_IMAGE。排查方法就是确认CMAKE_CUDA_ARCHITECTURES覆盖了运行机器的 Compute Capability。可以通过deviceQuery查看或者用python -c import torch; print(torch.cuda.get_device_capability())另一个常见的链接错误是undefined reference to __cudaRegisterLinkedBinary原因是链接时没有加上cudart库。CMake 中通过target_link_libraries(cuda_cnn PRIVATE CUDA::cudart)解决。如果直接使用 nvcc 命令行则要写-lcudart。3.4 多版本 CUDA 切换与环境变量热词里反复出现的“CUDA 多版本安装”在实操中往往栽在环境变量上。我建议在~/.bashrc里不要写死CUDA_HOME而是写一个软链接export PATH/usr/local/cuda/bin${PATH::${PATH}} export LD_LIBRARY_PATH/usr/local/cuda/lib64${LD_LIBRARY_PATH::${LD_LIBRARY_PATH}}其中/usr/local/cuda本身是一个软链接指向当前激活的版本。用update-alternatives切换软链接后所有环境变量自动跟着变也不需要改.bashrc。相关环境变量的常见问题见下表环境变量用途常见错误PATH搜索 nvccnvcc: command not foundLD_LIBRARY_PATH链接运行库libcudart.so not foundCUDA_HOME工具链查找CMake 找不到 CUDA另外注意如果你在用 CMake 构建CMake 会缓存编译器路径。切换 CUDA 版本后需要删除 build 目录重新配置否则 CMake 还在用旧 nvcc。这也是“cuda 迁移”时最常被忽略的点。4. 卷积、池化与反向传播CUDA 内核的完整拆解这一章我们直接看源码包里可能出现的实现。我会按前向、激活、池化、反向传播的顺序解释每段代码都可以单独编译运行。4.1 多通道卷积从单通道到特征图第二章的 tiled 版本只支持单通道。实际上 CNN 的输入通常是多通道比如 RGB 三通道或者前一层的 32 个特征图。处理多通道的常见做法是把通道维循环放在最外层但更好的方式是把通道轴和卷积核的累加合并成一个循环让每个线程计算一个输出像素时遍历所有输入通道。考虑输入 3 通道 32x32卷积核 3x3x3输出单通道 30x30。内核签名变成__global__ void conv_forward_multi_channel(const float* input, const float* weight, float* output, int channels, int in_size, int kernel_size, int out_size) { int row blockIdx.y * blockDim.y threadIdx.y; int col blockIdx.x * blockDim.x threadIdx.x; if (row out_size || col out_size) return; float sum 0.0f; for (int c 0; c channels; c) { for (int kh 0; kh kernel_size; kh) { for (int kw 0; kw kernel_size; kw) { float in_val input[(c * in_size (row kh)) * in_size (col kw)]; float w_val weight[(c * kernel_size kh) * kernel_size kw]; sum in_val * w_val; } } } output[row * out_size col] sum; }这里input的内存布局是 NCHW 的变种通道优先每通道连续。权重布局为[out_channels][in_channels][kh][kw]上面只处理了一个输出通道实际会有多个输出通道通常把输出通道映射到 block 的 z 维度或者直接线性展开。参数说明in_size是输入单通道边长out_size由(in_size - kernel_size 2*pad)/stride 1算出。如果不收敛先检查这个算式是否溢出。很多人第一次写 CUDA 卷积输出尺寸边界判断写错导致越界cuda-memcheck能帮你抓出来。下面用表格对比三种卷积实现方式的访问特性实现方式全局内存读取量共享内存适用场景naive 直接卷积每个输出像素读 KxK 次输入不使用验证正确性tiled 共享内存每个输入像素从全局内存读一次使用小卷积核、单batchim2col GEMM多一份 im2col 矩阵使用大batch、多通道4.2 融合 ReLU 的共享内存卷积内核在实际源码包中更常见的做法是把 ReLU 融合进卷积的内核避免单独启动一次激活内核。这样减少一次全局内存读写。修改上面内核只需要在写回 output 前执行一次激活float result fmaxf(sum, 0.0f); output[row * out_size col] result;fmaxf是 CUDA 内置函数直接映射到硬件指令比if (sum 0) outputsum; else output0;要高效因为它不会产生分支。对于带负斜率参数的 LeakyReLU可以用result sum 0.f ? sum : 0.01f * sum;虽然这里仍有分支但编译期会尽量使用选择指令。如果 LeakyReLU 的斜率是运行时变量可以做成__constant__。激活函数的融合是 CNN 算子优化最基本的一步源码包很可能已经做了这一步。4.3 最大池化内核实现池化层的作用是降采样没有可学习参数只需在窗口内找最大值。一个常见的误解是池化必须跟在卷积后面单独实现一个 kernel。其实最大池化可以像 ReLU 一样融合到卷积输出之后只要不关心中间结果。但为了结构清晰我们看单独的实现__global__ void maxpool_forward(const float* input, float* output, int in_size, int out_size, int pool_size) { int row blockIdx.y * blockDim.y threadIdx.y; int col blockIdx.x * blockDim.x threadIdx.x; if (row out_size || col out_size) return; int start_row row * pool_size; int start_col col * pool_size; float max_val -3.402823466e38F; // -FLT_MAX for (int dy 0; dy pool_size; dy) { for (int dx 0; dx pool_size; dx) { float val input[(start_row dy) * in_size (start_col dx)]; max_val fmaxf(max_val, val); } } output[row * out_size col] max_val; }这个内核的数据复用率很低每个输入像素只被读一次且遍历顺序是连续的所以直接使用全局内存是合理的不必上共享内存。pool_size通常为 2。4.4 反向传播卷积的梯度传递训练过程中反向传播的卷积层需要做两件事求输入梯度dx求权重梯度dw。对于一组输入和输出梯度dout输入梯度dx[i]等于所有与输入位置 i 相关的卷积窗口的权重乘以对应的输出梯度之和。本质上是一个“反向卷积”实现上可以把卷积核旋转 180 度对 dout 做 full 卷积。权重梯度dw[kh][kw]等于输入和 dout 在对应位置的乘积之和。如果把输入分块提取这又变成了一个矩阵乘法。在 CUDA 里最简单正确的实现是让一个线程负责一个dx坐标然后遍历所有能影响到它的输出位置。例如__global__ void conv_backward_dx(const float* dout, const float* weight, float* dx, int channels, int in_size, int kernel_size, int out_size) { int row blockIdx.y * blockDim.y threadIdx.y; int col blockIdx.x * blockDim.x threadIdx.x; if (row in_size || col in_size) return; float grad 0.0f; for (int c 0; c channels; c) { for (int kh 0; kh kernel_size; kh) { for (int kw 0; kw kernel_size; kw) { // 找到哪些输出位置使用了当前输入像素 int out_row row - kh; int out_col col - kw; if (out_row 0 out_row out_size out_col 0 out_col out_size) { grad dout[c * out_size * out_size out_row * out_size out_col] * weight[c * kernel_size * kernel_size kh * kernel_size kw]; } } } } dx[row * in_size col] grad; }这是最直观的版本边界条件很多但正确性容易验证。实际优化时会把这个反向过程也改写为分块加共享内存的版本或者直接通过 im2col GEMM 的思路用 cuBLAS 代替手写卷积。源码包选择手写多半是为了教学演示。权重梯度dw可以用类似方法写但要注意累加冲突。多个输入位置会累加到同一个权重位置如果让每个线程独立累加就需要atomicAdd。原子操作在浮点上是不确定的但量化误差可以接受。对于学习目的atomicAdd足够了。4.5 训练循环中的数据流完整训练一个 batch 的流程是把 BMP 图像从 CPU 复制到 GPU 全局内存。前向卷积 - ReLU - 池化 - 全连接。计算损失。反向全连接梯度 - 池化梯度 - ReLU 梯度 - 卷积梯度。权重更新。重复。其中 BMP 图像是 8 位的单通道图像需要转换为 float 并归一化。常见的做法是在 CPU 上直接除以 255.0。这里有一个优化点如果图像尺寸较小如 32x32可以把它当作常量内存存储整个 batch让所有 block 都能快速访问。但如果 batch 超过常量内存 64KB 限制就必须放全局内存。4.6 数值稳定性与梯度检查反向传播过程中最常遇到的问题是梯度变成 NaN。常见原因包括输入没有归一化、学习率过大、权重初始化不当。BMP 图像是 0-255 的 uint8直接转成 float 后数值范围较大卷积累加几十个数后会超过 1e4。一般做法是除以 255.0或者减去均值再除以标准差。如果仍然出现 NaN可以在每个权重更新后检查isnan并打印定位是哪一层开始出问题。梯度检查是验证反向传播正确性的金标准。先随机生成一个小输入用 CPU 数值差分计算近似梯度dW[i] ≈ (loss(W[i]ε) - loss(W[i]-ε)) / (2ε)然后和 CUDA 反向传播得到的梯度比较相对误差。如果误差小于 1e-5基本可以放心。这一步在源码包的测试代码里一定要有否则后续改算法很难判断是前向错误还是反向错误。5. 用 BMP 数据集验证与性能分析的两条捷径5.1 和 CPU 基准对比正确性手写 CUDA 内核最怕“结果错了但看起来对”。我通常先写一个单线程 CPU 参考实现对同一组输入跑一遍再在测试代码里调用 CUDA 内核对比输出。使用cudaMemcpy把结果拷回主机逐像素比较允许一个很小的绝对误差比如 1e-5。考虑到 BMP 是 8 位输入即使 float 误差也不会超过千分之一。一旦超过就打印第一个不一致的位置往往能快速定位越界或索引写错。./cuda_cnn_test --ref cpu --gpu gpu --tolerance 1e-5如果项目里没有这个开关自己加一个也不难。这一步能在半小时内完成省下的调试时间远超成本。5.2 CUDA 事件计时与 block 大小调整性能分析最直接的办法是 CUDA 事件而不是clock()。CUDA 事件在 GPU 时间线上打点得到的是 GPU 真实执行时间。用法如下cudaEvent_t start, stop; cudaEventCreate(start); cudaEventCreate(stop); cudaEventRecord(start); my_kernelgrid, block(...); cudaEventRecord(stop); cudaDeviceSynchronize(); float ms 0.0f; cudaEventElapsedTime(ms, start, stop); printf(kernel time: %.3f ms\n, ms);注意cudaDeviceSynchronize是必需的否则事件还没执行完就已经读取了。测量时关闭图形界面、后台任务避免驱动抢占影响结果。调整 block 大小时不必一个一个试。先在 16x16、32x8、16x8、8x32 等形状里粗扫一遍。共享内存版本的关键在于 tile 大小能否整除输出尺寸以及是否导致 bank 冲突。比如输出 30x30用 16x16 的 block 会产生 2x2 个 block边界线程部分空闲占用率不高。如果改成 32x16在满波次上更均匀。但也要看寄存器使用量可以通过-Xptxas -v编译选项查看每个线程使用多少寄存器。5.3 访存优化的实测经验对于手写 CNN多数性能瓶颈不是算力而是访存。举个例子一个 3x3 卷积如果不用共享内存每个输出像素需要读 9 个输入像素相邻线程重复读了大约 3 次。用上共享内存后每个输入像素从全局内存只读一次。在我的测试里这个改动在 GTX 1080 上能让前向时间缩短近 5 倍。另一个经验是不要在每个卷积层写一个孤立的 Kernel把 ReLU 和 MaxPool 融合进卷积输出能再省 10%-20% 的全局内存流量。5.4 结合 CUDA Graphs 减少启动开销如果训练循环是串行启动几十个 Kernel每个 Kernel 的启动开销约 5-10 微秒累加起来不可忽略。CUDA Graphs 可以一次录制整个前向反向图然后重放把每次迭代的启动开销降到接近一个 Kernel。代码改动不大把循环中的 Kernel 启动替换为cudaGraphLaunch。对于这个源码包我建议在训练循环稳定后用 Graph 包裹前向 反向 权重更新在 Nsight Systems 里能看到明显的间隙消失。最后始终带着验证函数跑回归改一次共享内存布局跑一次对比就能知道优化是否真的有效而不是靠感觉。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价