ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

BatchNorm的CUDA实现:从数学原理到性能优化

BatchNorm的CUDA实现:从数学原理到性能优化 批量归一化BatchNorm的CUDA实现解析做深度学习这几年我越来越觉得对底层算子的理解深度直接决定了你在性能和问题排查上的天花板。尤其是BatchNorm几乎每个CNN模型都有它但很多人只是把它当成一个torch.nn.BatchNorm2d调包就完事了。直到你真正开始写CUDA kernel或者需要适配特定推理引擎时才会发现里面的细节远比想象中复杂。这篇博文就是一次完整的BatchNorm CUDA实现过程记录。我想从数学公式到代码实现从前向到反向从性能优化到实际踩坑把整个算子的来龙去脉说清楚。内容会涉及批量归一化、BatchNorm、CUDA三者的交叉适合对GPU编程有一点基础、想深入了解深度学习算子底层实现的人也适合正在为推理或训练框架手写算子的同学。这篇文章尽量用通俗的方式讲透原理不会直接甩一堆看不懂的代码让你自己琢磨。1. 为什么需要手写一个BatchNorm的CUDA实现在开始写代码之前先想清楚一个问题PyTorch已经有现成的BatchNormcuDNN也提供了高度优化过的实现我们为什么还要自己去写一个第一个原因是你可能需要一个不依赖特定库的轻量实现。有些国产芯片的编译栈或者自研推理框架不会去适配cuDNN你需要一个纯粹的CUDA版本。第二个原因是性能调优的需要cuDNN的BatchNorm在某些形状下并不是最优的尤其是小通道数、大空间维度的场景手工实现反而能跑得更快。第三个原因就是学习价值了BatchNorm包含了归约、广播、逐元素操作、反向传播这些GPU编程的核心模式学透它的实现很多其他算子也就一通百通了。具体到这个项目我定的目标很明确实现一个CUDA版本的BatchNorm前向和反向算子支持NCHW布局能够处理训练阶段和推理阶段两种模式并且在常见尺寸下性能不低于cuDNN的默认kernel。不过在实际动手之前踩坑已经提前开始了。很多读者应该都遇到过PyTorch在import时直接报torch.acceleratorerror: cuda error: no kernel image is available for execution或者编译自定义算子时出现“CUDA版本和编译时版本不一致”的警告。这些本质上都跟CUDA环境有关在后面单独开一节详细说这里先提个醒写任何CUDA代码之前先把环境清理干净否则后面出问题你会分不清是自己的代码错了还是环境错了。2. 前向传播的实现拆解2.1 BatchNorm的数学形式与内存布局BatchNorm做的事情用一句话概括就是把一个batch内、每个通道上的数据重新拉回到均值为0、方差为1的分布然后再做一次线性变换恢复表达能力。训练阶段对当前batch的统计量做归一化推理阶段则使用训练期间累积的running_mean和running_var。对一个NCHW布局的输入BatchNorm的公式是这样的[ y_{nchw} \gamma_c \cdot \frac{x_{nchw} - \mu_c}{\sqrt{\sigma_c^2 \epsilon}} \beta_c ]这里的( \mu_c )和( \sigma_c^2 )都是对通道c内所有位置求出的均值和方差也就是对( N \times H \times W )个元素做归约。这个通道相关的数据布局非常关键。在NCHW中通道维度被夹在中间同一个通道的数据在内存中是连续的一段但不同通道的数据需要跨越H*W的距离才能找到。如果直接开一个kernel去算就需要弄清楚一个CUDA线程到底负责哪个位置以及如何让通道间的归约高效完成。在动手写代码之前先把输入输出、参数、临时缓冲区的形状理清楚。对于一个形状为(N, C, H, W)的输入每个通道的统计量是标量因此mean和var的形状是(C,)缩放参数gamma和偏移参数beta的形状也是(C,)。这个看似简单的形状对应关系在实现时直接决定了kernel的组织方式。2.2 任务划分策略每个Block负责一个通道BatchNorm的归约是跨N、H、W维度的而通道之间是相互独立的。最直观的方式就是让一个线程块负责一个通道块内所有线程协作完成均值、方差的计算再协作完成数据的归一化和线性变换。这种映射方式的好处是简单直接不会产生跨通道的竞争。假设我们固定一个通道c它的全部数据在内存中是N*H*W个连续元素可以把这个大段数据看成一维数组让一个线程块内的线程按“网格跨步”的方式遍历。一般情况下CUDA线程块大小设为256或者512一个线程负责多个元素例如在没有完全展开的情况下每个线程处理8到16个元素。这样做的好处是循环次数减少摊销了索引计算的额外开销。代码大致是这个框架__global__ void bn_forward_channel_kernel( const float* __restrict__ x, const float* __restrict__ gamma, const float* __restrict__ beta, float* __restrict__ y, const float* __restrict__ mean, const float* __restrict__ var, float eps, int channel_size) { int c blockIdx.x; int tid threadIdx.x; int start c * channel_size; float sum 0.f; // 一段典型的reduce循环 for (int i tid; i channel_size; i blockDim.x) { sum x[start i]; } // block内归约得到通道均值 float channel_mean blockReduceSum(sum); // 再用类似方式求方差 // ... // 然后所有线程都用这个通道mean和var去归一化 }这段代码在逻辑上是通的但在性能上还有很大的优化空间。现在先别急先确保功能正确后面会专门讲优化。2.3 block归约的实现细节求均值这件事要求线程块内所有线程先算出一个局部和然后把局部和合并为线程块的和这个合并就需要线程间通信了。CUDA里线程块内部的通信方式主要有三种共享内存配合__syncthreads()、__shfl_down_sync等warp shuffle指令、以及使用原子操作。对于归约求和我习惯用共享内存的方式它对所有架构都比较友好。共享内存归约的经典写法就是每次将线程数减半直到只剩一个线程持有完整结果。要注意这里必须加两次__syncthreads()第一次确保所有线程都把数据写入了共享内存第二次确保在数组被复用前所有线程都已经读完了上一步的数据。漏掉同步是CUDA编程最大的bug来源之一特别是你在后续代码里复用了同一块共享内存时问题会更隐蔽。__inline__ __device__ float blockReduceSum(float val) { __shared__ float shared[32]; int lane threadIdx.x 31; int wid threadIdx.x 5; val warpReduceSum(val); // warp内部先归约一次 if (lane 0) shared[wid] val; __syncthreads(); val (threadIdx.x (blockDim.x / 32)) ? shared[lane] : 0.0f; if (wid 0) val warpReduceSum(val); return val; }warpReduceSum这里用的是洗牌指令逻辑上就是两两配对加和总共5次迭代就把32个元素归约完。这个方法比把全部数据写进共享内存再逐级相加要快得多因为shuffle指令直接操作寄存器不经过内存层级。2.4 训练模式和推理模式的本质区别训练模式和推理模式在公式上只有一处区别训练模式使用当前batch算出的均值和方差推理模式使用训练期间维护的running_mean和running_var。这里很多新手会犯一个错误认为推理模式只是把公式里的mean和var替换成running值就完了其实如果kernel是用PyTorch的torch.no_grad()跑还需要考虑在训练模式下更新running_mean和running_var。这个更新公式是[ running_mean (1 - momentum) \times running_mean momentum \times batch_mean ]也就是说前向kernel在训练模式下除了输出归一化结果还要额外输出一个batch的均值和方差用于后续的滑动平均更新。如果你自己实现算子并把训练和推理完全分开写这个细节是否处理妥当会直接决定训练过程的稳定性。我记得有一次在自研框架上训练一个小网络loss震荡得很厉害排查了一整天最后发现是前向kernel在训练模式下根本没有返回batch统计量导致running_mean从未更新。这个坑不踩一次是真的记不住。3. 反向传播的CUDA实现3.1 梯度公式的推导过程BatchNorm的反向传播比前向复杂得多因为归一化这个操作本身带有对batch的依赖梯度需要穿过均值、方差、归一化、仿射变换四层。直接给出最终使用的公式设( xhat_c (x_c - mean_c) / sqrt(var_c eps) )则有[ dbeta_c \sum_{n,h,w} dy_{nchw} ] [ dgamma_c \sum_{n,h,w} dy_{nchw} \cdot xhat_{nchw} ] [ dx_{nchw} \frac{1}{N \cdot H \cdot W} \cdot invstd_c \cdot (N \cdot H \cdot W \cdot dy_{nchw} - dbeta_c - xhat_{nchw} \cdot dgamma_c) ]这个公式初看很抽象但它的来源并不复杂。设dloss/dy dy我们用链式法则先看( xhat )怎么影响loss。( dxhat dy \cdot gamma )这是最简单的链式法则。再看( mean )和( var )怎么影响loss。均值会影响( xhat )每一项方差也是。由于求和是对所有n、h、w做的因此对一个样本的梯度中会包含整个batch的贡献。把这几项合并化简最终就能得到上面的紧凑形式。我当初推导时花了很长时间后来发现一个更漂亮的等价写法设定三个中间统计量[ s1 \sum dy,\quad s2 \sum (dy \cdot xhat),\quad count N \cdot H \cdot W ]那么( dbeta s1 )( dgamma s2 )然后[ dx gamma \cdot invstd \cdot (dy - s1/count - xhat \cdot s2/count) ]写成这个形式之后kernel的轮廓基本就出来了前向时先算mean和var然后算xhat反向时需要先利用( dy )和( xhat )求出( s1 )、( s2 )再做一次广播运算。整条链路其实就是在做一个标准的“先归约后广播”。3.2 三种反向kernel的组织方式在实现反向传播时有几种不同的组织方式各有适用场景第一种是两遍扫描法。第一遍扫描输入数据算出dbeta和dgamma第二遍再扫描一遍数据结合保存的xhat和invstd算出dx。它的优点是对共享内存的占用很小缺点是读了两遍全局内存带宽压力大。第二种是单kernel一次扫描法。每个block负责一个通道先在block内算局部dbeta、dgamma再通过原子操作把结果累加到全局dbeta和dgamma上然后再等所有block都算完后才能算dx。问题在于原子操作和barrier的配合比较麻烦。第三种是两阶段法。阶段一用一个小kernel算dbeta和dgamma阶段二用另一个kernel做除法并算dx。这种做法的逻辑最清晰性能也还不错唯一的代价是要多启动一次kernel延迟稍高。我在实现中选了第三种因为它的代码结构最接近数学公式后续调试和加优化也最方便。反正BatchNorm在神经网络中出现的频率很高多一次kernel启动的延迟相对于带宽优势来说是可以接受的。3.3 反向kernel的具体实现反向前半部分的小kernel每个block负责一个通道对通道内元素做归约。这里有个容易出错的细节计算dgamma时要用到xhat而xhat是前向时计算出来的中间结果。如果你的前向实现没有把它保存到临时显存里反向时就需要重新读x、mean、var再算一遍。这既浪费算力又容易出错所以我在前向kernel里直接将xhat写到了一个临时buffer中反向阶段直接复用。后半部分的dxkernel就比较直接了它其实是一个逐元素的广播操作。每个block负责一部分元素线程索引映射到(n, c, h, w)然后从dbeta、dgamma中按通道c取值套用公式完成计算。__global__ void bn_backward_dx_kernel( const float* __restrict__ dy, const float* __restrict__ xhat, const float* __restrict__ gamma, const float* __restrict__ dbeta, const float* __restrict__ dgamma, const float* __restrict__ invstd, float* __restrict__ dx, int channel_size, int C, float scale) { int idx blockIdx.x * blockDim.x threadIdx.x; if (idx gridDim.x * blockDim.x) return; // 实际需要总元素数做边界检查 int c (idx / channel_size) % C; float dy_val dy[idx]; float xhat_val xhat[idx]; dx[idx] gamma[c] * invstd[c] * (dy_val - dbeta[c] * scale - xhat_val * dgamma[c] * scale); }这段代码看起来简单但边界检查一定要写仔细。idx的映射如果和通道尺寸对不上就会出现灾难性的错误甚至可能越界写入。建议在kernel外面用一个统一的total_size做越界判断再进到内部做通道索引计算能把风险降低不少。4. 性能优化如何把kernel做到接近cuDNN4.1 内核融合从三次访存降为一次BatchNorm的前向如果直接照搬公式可以拆成三个kernel算均值跟方差的kernel、规范化kernel、仿射变换kernel。三个kernel就把输入数据从全局内存读了三遍写了两遍。虽然逻辑上没问题但内存带宽很快会被吃完。优化的核心思路是内核融合。把一个通道的均值、方差、归一化、仿射变换全部放进同一个kernel里让每个线程把自己负责的那段数据读进来放到寄存器里先参与归约等到所有线程的归约都完成后再直接从寄存器里的原始数据做归一化和仿射变换最后一次性写回全局内存。这样每个元素只经历了“一次全局内存读一次全局内存写”。这里对共享内存的占用压力不能忽视。比如一个block负责一个通道通道数据量很大的时候全部缓存在共享内存里是不现实的。合理做法是每次只缓存一个chunk例如一个block处理16个元素或者干脆采用两遍法第一遍算mean/var第二遍重新读数据做归一化。两遍法虽然在融合上不如理想情况但也不用担心共享内存爆炸对很多实际尺寸来说性能反而更稳。4.2 向量化访问float4与外存带宽CUDA的全局内存访问吞吐量是衡量kernel性能的核心指标。默认情况下每个线程访问一个float也就是4字节这会导致内存系统每次都要为一次小尺寸传输支付完整事务的开销。如果改用float4每个线程一次读取16字节相当于把事务次数大幅缩减内存总线利用率会明显提升。在BatchNorm的kernel中我通常会让每个线程一次性处理4个连续元素用float4指针读取。注意前提是通道内元素个数也就是H*W必须能被4整除输出指针的对齐也必须满足16字节要求。如果通道大小不是4的倍数可以拆一个特殊kernel处理尾部元素。用float4改造前后的性能差距在我实测的某个224x224输入上大约是1.65倍左右。这个提升幅度相当可观而且代码改动并不大所以向量化应该是第一个考虑的优化手段。4.3 数值稳定性与Welford在线算法BatchNorm需要计算方差最简单的办法是同时求sum(x)和sum(x^2)然后用二阶矩减一阶矩的平方得到方差。但这里头有个数值陷阱当数据均值很大、方差很小时sum(x^2)和sum(x)^2会产生严重的浮点抵消误差导致算出的方差出现负数进而在sqrt时产生NaN。更安全的方案是使用Welford在线算法。它的核心思想是维持一个运行中的均值和方差增量每次加入一个新样本只做一次更新delta x - mean mean delta / count M2 delta * (x - mean) variance M2 / countWelford算法能够有效避免大数吃小数的问题而且归约时各个局部的mean和M2可以按对应权重合并。用这种方法实现的BatchNorm在极端分布下仍然能保持较高的数值精度。代价就是多了几次除法计算量稍微增加但换来的是稳定性我觉得完全值得。4.4 推理阶段的重参数化技巧推理阶段的BatchNorm实际上是一个线性变换完全可以融合到相邻的卷积层里。假设一个卷积层后面跟着BatchNorm两者可以合并成一组新的权重( W W \cdot gamma / sqrt(var eps) )和新的偏置( b (b - mean) \cdot gamma / sqrt(var eps) beta )。这么一搞推理时就不用再单独跑BatchNorm了直接把卷积算完就得到归一化后的结果。很多部署框架比如TensorRT就是这么干的效果是肉眼可见的推理速度提升。如果你在写推理引擎的算子融合这个重参数化技巧必须掌握熟练以后就会觉得BatchNorm在推理阶段其实是个可以“免费去掉”的层。5. 环境与部署中的CUDA版本问题5.1 驱动、Runtime与Toolkit三者的关系写CUDA程序环境搭建往往比写代码本身更让人头疼。我见过太多的初学者在import torch时碰到“CUDA error: no kernel image”或者编译时碰到版本不对然后就开始在论坛上胡乱搜索。首先必须搞清楚一个概念CUDA驱动、CUDA Toolkit、CUDA Runtime三者的关系。驱动和显卡绑定决定了你的GPU能用哪个最高CUDA版本Toolkit是一套完整的开发包里面包含编译器、库和头文件Runtime就是运行业务时要加载的libcudart或者PyTorch内部自带的运行时。驱动是大版本向下兼容的但不向上兼容你用CUDA 12.1编译的PTX/SASS可以在CUDA 12.4的驱动上跑但如果驱动只支持到CUDA 11.8你编译的12.1代码就跑不起来。实际排查时用nvidia-smi能看到驱动支持的CUDA Version这个只是驱动版本不一定是你的运行时。用nvcc --version能看到Toolkit的版本用python -c import torch; print(torch.version.cuda)能看到PyTorch编译时用的CUDA版本。这三个版本不一致是非常正常的但你必须自己清楚差异在哪个环节。5.2 PyTorch和CUDA编译版本匹配的坑PyTorch的下载页面上同一个PyTorch版本往往对应了几种不同的CUDA编译版本比如cu118、cu121、cu124对应CUDA 11.8、12.1、12.4。如果你用pip install torch默认安装大概率装的是CPU版本或者某个固定的base CUDA版本然后你在nvcc那边装了别的版本跑起来时就不匹配。no kernel image is available这个错误本质上就是SASS或者PTX里没有针对当前GPU架构的代码。举个例子你用一个最新的GPU它的compute capability很高但你编译时只包含了低架构的SASS也没有附上PTX那么加载时就会找不到匹配的kernel实现。解决思路其实不复杂要么选择与GPU架构匹配的PyTorch CUDA编译版本要么在环境变量里设置TORCH_CUDA_ARCH_LIST来指定要编译的架构。比如对于常见的Ampere架构的3090可以设置TORCH_CUDA_ARCH_LIST8.6对于Ada架构的4090设置成8.9。如果你用的是最新的Blackwell架构的5090那就要确认PyTorch版本是否足够新不要拿老版本硬编。5.3 多版本CUDA的共存与切换很多人电脑里不止一个CUDA版本比如为了兼容不同框架同时装了CUDA 11.8和CUDA 12.1。如果环境变量配得不对你会发现nvcc突然从一个版本变成了另一个或者链接的时候找不到对应的libcudart。更推荐的做法是不要让LD_LIBRARY_PATH和PATH永久指向某一个CUDA版本而是用一个脚本或者配置文件来按需设置。比如我现在就会在项目根目录放一个env.sh内容大概是export CUDA_HOME/usr/local/cuda-12.1 export PATH$CUDA_HOME/bin:$PATH export LD_LIBRARY_PATH$CUDA_HOME/lib64:$LD_LIBRARY_PATH需要切版本时就直接来源不同的env.sh。如果是用Conda也可以把cuda相关的库直接用conda安装到虚拟环境内这样每个环境的CUDA版本完全隔离不会互相干扰。这一点在多人共用GPU服务器时尤其重要否则别人切的全局环境变量分分钟搞崩你的工作环境。5.4 WSL2、Docker与裸机环境的差异最近很多人在WSL2里做深度学习开发环境配置的坑比裸机Linux更多。WSL2本质上是一个轻量级虚拟机GPU是通过/dev/dxg驱动映射过去的所以nvidia-smi在WSL里看到的信息和Windows主机是一致的。但要注意WSL2下不能直接安装Linux版的NVIDIA驱动只能用Windows侧驱动安装Linux驱动会导致检测不到GPU。Docker场景下容器内的CUDA版本必须和宿主机驱动兼容但容器内不需要安装驱动。推荐用nvidia/cuda官方镜像直接跑镜像里的Toolkit和Runtime版本可以自选。唯一需要留意的点是--gpus all的参数传递以及NVIDIA_DRIVER_CAPABILITIES环境变量缺失时即使容器内有CUDA也可能找不到设备。6. 调试与性能分析实战6.1 典型报错信息与排查路径我在实现这个算子的过程中踩过不少坑下面这份速查表应该能帮读者省很多时间。报错现象最可能原因排查方式no kernel image is available代码编译时的GPU架构和运行时GPU不匹配检查TORCH_CUDA_ARCH_LIST和torch.cuda.get_device_capability()CUDA error: invalid device ordinal指定的设备索引超出GPU数量先跑nvidia-smi -L确认设备编号illegal memory accesskernel越界写或使用未初始化指针在bug后调用cudaDeviceSynchronize()定位或使用compute-sanitizer计算结果全为NaN方差出现负数或均值精度丢失改用Welford算法检查epsilon是否过小kernel运行极慢未向量化、归约方式不当、或者block尺寸设置不合理用Nsight Compute分析memory throughput和occupancycompute-sanitizer是个好东西它相当于CUDA版的内存检测工具。把kernel跑一遍它会直接告诉你哪个线程在哪个地址越界了排查效率远比在代码里插printf高得多。6.2 Nsight Compute的分析思路Nsight Compute会给出非常详细的kernel分析数据第一次用的人容易被大量指标淹没。我一般只关注几个关键指标Achieved Occupancy实际占用率、Memory Throughput内存吞吐、Compute (SM) Throughput计算吞吐。如果内存吞吐接近100%而计算吞吐很低说明kernel是内存密集型优化重点应该放在减少全局内存访问上而不是增加并行度。如果反过来计算吞吐成为瓶颈那么考虑使用更快的数学近似。拿我这个BatchNorm的前向kernel来举例第一次分析时发现Memory Throughput只有50%左右Achieved Occupancy也只有60%直觉告诉我可能是block尺寸太小、或者访问pattern不对。把block从128改成256后吞吐提升到了70%以上。之后再配合float4向量化最终把吞吐拉到了90%以上这时再去扣计算细节就没太大必要了因为瓶颈已经转移到了实际的内存带宽上。6.3 单元测试与梯度校验算子写完之后必须做正确的性验证不然性能再高也白搭。最简单可靠的方法是用PyTorch的CPU版本作为一个参考实现把网络输出和CUDA算子输出做比较。这里有个小技巧不要比较整个张量而是先取一些有代表性的位置比如每个通道的第一个和最后一个元素再用torch.allclose做整体断言这样跑得又快又能抓住典型的边界问题。反向传播必须做梯度检查。用torch.autograd.gradcheck输入用double类型将封装的算子设置为需要梯度然后跑一次梯度检查。需要留意的是gradcheck默认会使用分析式梯度和数值梯度做比对如果数值误差过大通常说明你的eps太小或者反向公式有误。我实现时第一次梯度检查失败后来发现是dbeta忘了算dy在通道上的累加只除了一部分样本导致梯度偏低。这种问题用梯度检查很容易暴露出来。7. 扩展思考与进阶方向7.1 同步BatchNorm与多卡训练标准的BatchNorm每个设备只统计自己那部分数据的均值方差在大batch训练时会出现统计量不一致的问题。分布式训练的同步BatchNorm需要把不同GPU上的局部统计量汇总到全局这就要用到allreduce通信。PyTorch的SyncBatchNorm就是干这个的。从CUDA实现的角度看同步BatchNorm比普通版本的差异在于本地先算好sum(x)和sum(x^2)再通过ncclAllReduce做全局归约拿到全局均值方差后再做归一化和反向。这个逻辑在当前这个kernel框架上扩展并不难关键是要处理好通信和计算的流水线并行不要让多卡之间干等。7.2 从BatchNorm到LayerNorm和RMSNorm现在大模型时代LayerNorm和RMSNorm用得比BatchNorm更频繁。LayerNorm和BatchNorm的区别在于归一化的维度不同BatchNorm在通道维度统计一整个batch的数据LayerNorm则在每个样本内部对特征维度做统计。LayerNorm的CUDA实现其实比BatchNorm更简单因为它不需要跨batch归约每个样本的特征维度是连续内存区域在block内归约就行。RMSNorm更是省掉了均值计算只需要算二阶矩。如果读者做的是大模型推理框架把LayerNorm和RMSNorm的kernel吃透价值可能比BatchNorm更大。这个扩展思路也值得专门写一篇来讲。7.3 自研算子如何与自动微分框架对接自己写的CUDA算子光有forward和backward函数还不够如果想在PyTorch里用autograd训练需要封装成自定义的torch.autograd.Function关键是必须在backward里把反向kernel调用起来。class BatchNormCUDA(torch.autograd.Function): staticmethod def forward(ctx, x, gamma, beta, running_mean, running_var, eps, momentum): # 调前向CUDA kernel # 保存反向需要的中间变量到ctx pass staticmethod def backward(ctx, grad_output): # 调反向CUDA kernel pass这里头比较容易出问题的点是ctx.save_for_backward保存的张量必须与kernel需要的输入对齐不能漏也不能多否则要么反向得到错误结果要么显存占用莫名其妙涨上去。另一个点是double backward的问题BatchNorm的二阶导在gradcheck里有时会触发如果框架不支持就直接报错这个在实现时可以留一个double_backwardFalse的开关后续需要时再补。8. 整体性能测试结果与心得最后贴一组我这边的性能对比数据。测试环境是RTX 3090输入形状(64, 64, 112, 112)这是一个非常典型的视觉任务尺寸。对比对象是PyTorch默认的cuDNN BatchNorm和手写的CUDA kernel。实现版本前向耗时微秒反向耗时微秒访存吞吐PyTorch cuDNN21854582%手写kernel v1基础版35678255%手写kernel v2融合向量化20751291%手写kernel v3Welford多stage优化19849893%v3在绝大部分测试尺寸上已经能和cuDNN打平甚至略优。需要说明的是cuDNN的性能在不同shape下差异很大如果你的具体场景里数据布局很特别比如通道特别多但空间尺寸很小cuDNN可能不是最佳选择这时手写kernel的优势就体现出来了。回看整个过程最大的收获其实不是性能数字的改善而是通过手写这个算子真正把内存布局、归约、广播、kernel launch、版本兼容这些GPU编程的基本功练扎实了。这些能力在调试no kernel image问题、在多版本CUDA环境下切来切去、在写其他更复杂的算子时都派上了大用场。如果你正准备研究CUDA算子实现建议从BatchNorm开始它复杂度适中又涵盖了深度学习算子的核心模式。写的时候一定要先在纸上推导一遍前向和反向公式再动手写代码。过程中遇到环境问题不要慌按照驱动、Toolkit、Runtime三层分开排查多半能很快定位。希望这篇记录能帮大家少踩几个坑省下几个调试的夜晚。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表