当前位置: 首页 > news >正文

CUDA自学笔记01—Reduction规约求和

目录

  • 备注
  • Reduction规约求和
  • Reduce朴素版本
  • Reduce1——使用共享内存
  • Reduce2——去除分支发散和除法操作
    • bank conflict计算
  • Reduce3——减少Bank Conflicts
  • Reduce4——减少空闲线程
  • Reduce5——warp展开
  • Reduce6——for循环进一步展开
  • Reduce7——网格步幅循环加载
  • Reduce8——warp shuffle
  • 参考文献

备注

这个笔记是在自学过程的一些记录,中间包含了很多个人的理解以及问AI的回答,因为找不到很系统的教程,官方文档有时候不会讲的特别细,比方说某个指标的具体计算过程网上也没找到什么资料,所以在这里记录下,后续自己有了新的理解或者发现写的不对的地方都会在这不断更新,如有不对的地方,还请指正。

Reduction规约求和

Reduction就是对一组数据经过一些操作变换成一个结果,这里以求和为例进行记录。


每个数加上它右边间隔s的数,开始s=1,每迭代一轮s*2,迭代log₂ⁿ轮,n是数据量,这里假设n是2的幂次。迭代完后第一个数就是求和结果。

测试过程数据是4000000个int的求和,然后默认blocksize=512,显卡是RTX 4070 Super。

Reduce朴素版本


kernel代码如下:

__global__voidreduce_naive(int*g_idata,int*g_odata){inttid=threadIdx.x;inti=blockIdx.x*blockDim.x+threadIdx.x;for(ints=1;s<blockDim.x;s*=2){if(tid%(2*s)==0){g_idata[i]+=g_idata[i+s];}__syncthreads();}if(tid==0)g_odata[blockIdx.x]=g_idata[blockIdx.x*blockDim.x];}

这段代码相当于在每个block的512个thread里面做规约,g_odata里面是每个block里512个数据的和,最后把g_odata求和就得到最终结果。

Reduce1——使用共享内存

我们都知道,共享内存能够帮助我们减少内存搬运耗时,因为它是片上内存,我们可以先把数据搬运到共享内存上再去计算。

kernel代码如下:

__global__voidreduce1(int*g_idata,int*g_odata){extern__shared__intsdata[];inttid=threadIdx.x;inti=blockIdx.x*blockDim.x+threadIdx.x;sdata[tid]=g_idata[i];__syncthreads();for(ints=1;s<blockDim.x;s*=2){if(tid%(2*s)==0){sdata[tid]+=sdata[tid+s];}__syncthreads();}if(tid==0)g_odata[blockIdx.x]=sdata[0];}

这里我用Nsight Compute分析对比上面两个kernel时发现耗时没有明显降低,然后上网搜索了一下说是Naive的全局显存数据请求到数据后会暂存在L2 cache中复用,所以相对于共享内存版本耗时差异不大。从下表中可以看到Naive版本的L2 cache throughput很高。(PS:这里其实没有完全理解,在这里Mark一下。我当前的猜测是因为大部分操作数据在内存上都是相邻的,一次事务可以取32个4字节的int数,所以内存事务复用率较高,所以和共享内存版本差异不大)

Reduce2——去除分支发散和除法操作

Reduce1有两个问题,一个是分支发散比较严重,另一个是’%'操作比较耗时,需要优化一下。

__global__voidreduce2(int*g_idata,int*g_odata){extern__shared__intsdata[];inttid=threadIdx.x;inti=blockIdx.x*blockDim.x+threadIdx.x;sdata[tid]=g_idata[i];__syncthreads();for(ints=1;s<blockDim.x;s*=2){intidx=2*s*tid;if(idx<blockDim.x){sdata[idx]+=sdata[idx+s];}__syncthreads();}if(tid==0)g_odata[blockIdx.x]=sdata[0];}

从下表可以看出我们实现了规约操作,且减少了分支发散和除法操作,但是这里带来了一个新的问题就是引入了Bank conflict。例如第一轮迭代tid=0的线程会读取idx=0的数据,tid=16的线程会读取idx=32的数据,而这两个地址的数据在同一Bank0(idx%32=0)里面,这就相当于不同线程在读取同一Bank的不同地址的数据,这就是Bank conflict,导致并行变成串行,增加耗时。不过相比于前面的kernel,还是能看到耗时明显降低,说明之前的kernel分支发散和除法操作比bank conflict更耗时。

bank conflict计算

先按一个512个int、blocksize=512的demo计算bank conflicts

指标定义参考Nvidia Nsight Compute官方文档


对于512个线程,每个线程处理一个int数据,可以分为512/32=16个warp

Share Store:
1、对于sdata[tid] = g_idata[i];这是在给共享内存写入,所以对应Share Store,总共16个warp,对应16个Instructions和Requests;这里由于都是连续写入,所以不存在bank conflicts,wavefronts=16

2、对于sdata[idx] += sdata[idx + s],第一次迭代也就是s=1的时候是只在idx——0~255的索引下写入,所以只有8个warp,总共9次迭代依次是8、4、2、1、1、1、1、1、1个warp在做共享内存写入,所以对应8+4+2+1+1+1+1+1+1=20个Instructions和Requests;

这里我举了s=1和s=16时的索引,可以看到s=1时,idx和idx+s都是间隔2的索引,就是一个2-way的bank conflict,一个warp就需要2次transactions,依次类推s=2时4-way的bank conflict需要4次transactions,而到了s=16时,注意tid=16时,idx已经到了512,索引已经超出了,超出的这部分是无效的,虽然这个warp是32-way的,但是我只需要发送16次transactions就满足了,后续迭代以此类推。最终得到1次load或1次store的wavefronts=16+16+16+16+16+8+4+2+1=95

综合下来Share Store Instructions和Requests = 16 + 20 = 36,wavefronts=95+16=111

Share Load:
1、对于sdata[idx] += sdata[idx + s],分析思路和Share Store一致,不过有个区别是这里每个线程读取是有两个地址idx和idx+s,所以Share Load Instructions和Requests = 20 + 20 = 40;wavefronts参考前面的Store分析wavefronts=95*2=190(乘2是因为做了两次读取)

2、if (tid == 0) g_odata[blockIdx.x] = sdata[0];这行语句也有一个读取共享内存操作,不过只做一次; sdata[0]相当于做了一次内存读取,也就相当于一次wavefront
综合下来Share Load Instructions和Requests = 40 + 1 = 41,wavefronts=190+1=191

而bank conflicts就相当于额外的wavefronts,结果也就等于总的wavefronts减去请求的requests
Share Load Bank Conflicts = 191 - 41 = 150
Share Store Bank Conflicts = 111 - 36 = 75
注意到这里Load的冲突数刚好是Store的两倍,这是因为发生冲突的语句sdata[idx] += sdata[idx + s]刚好是两次读取一次写入。

我后面又尝试了不同尺寸的输入,发现Share Load wavefronts不能通过单个block的wavefronts * 总的blocks计算(而且每次跑可能会不一样,虽然相比于总数不会差太多),其他几个都可以。问了下AI说是Wavefronts 是流水线层面的“物理动作”。在 GPU 的硬件流水线中,当某几个 Warp 的执行由于某些外部原因(例如抢占、上下文切换、或规约中的 __syncthreads() 同步导致暂停再恢复时),LSU 阶段可能为了确保数据的危害(Hazard)检测通过,在极个别周期发生了物理重复请求(Retry)。这种极低概率的硬件自发抖动,会被物理计数器记录下来。

Reduce3——减少Bank Conflicts

我们可以将求和的间隔由小到大改成由大到小,如下图所示,这样就可以避免绝大多数的Bank conflict

可以看到Bank Conflits明显减少

Reduce4——减少空闲线程

前面的Reduce方法有比较多的线程冗余,比如说我们每个block开了512个线程,但是后面256个线程只做了一次数据搬运就没有其他操作了,第一轮迭代只有前面256个线程在做加法运算运行,后面256个线程就都空置了,后续每次迭代空置的线程也就越来越多。
为了把线程利用起来,可以在把数据往共享内存搬运的时候就做一次加法,比方说我一个block还是512个线程,但是我用这512个线程处理1024个数据,在把数据从全局内存搬运到共享内存的时候,我读取第0个数据和第512个数据,把他们相加然后写入共享内存,然后一直到第511个数据和第1023个数据,把他们相加然后写入共享内存,这样就相当于每个线程都做了一次加法操作,相比上面的Reduce把空闲线程也做了利用。

__global__voidreduce4(int*g_idata,int*g_odata,intsize){extern__shared__intsdata[];inttid=threadIdx.x;inti=blockIdx.x*(blockDim.x*2)+threadIdx.x;if(i<size)sdata[tid]=g_idata[i]+g_idata[i+blockDim.x];elsesdata[tid]=0;__syncthreads();for(ints=blockDim.x/2;s>0;s>>=1){if(tid<s){sdata[tid]+=sdata[tid+s];}__syncthreads();}if(tid==0)g_odata[blockIdx.x]=sdata[0];}

这里需要注意,由于我们每个block是用512个线程处理1024个数据,所以总的block数相比于之前的Reduce要减半。

可以看到kernel耗时显著降低

Reduce5——warp展开

我们知道一个warp里面有32个线程,且是同步执行的,所以当间隔s从大间隔降到32的时候,我们可以不用 __syncthreads()命令,直接把内部规约展开,注意kenel中for循环中止条件从s>0变成了s>32

__device__voidwrapReduce5(volatileint*sdata,inttid){sdata[tid]+=sdata[tid+32];sdata[tid]+=sdata[tid+16];sdata[tid]+=sdata[tid+8];sdata[tid]+=sdata[tid+4];sdata[tid]+=sdata[tid+2];sdata[tid]+=sdata[tid+1];}__global__voidreduce5(int*g_idata,int*g_odata,intsize){extern__shared__intsdata[];inttid=threadIdx.x;inti=blockIdx.x*(blockDim.x*2)+threadIdx.x;if(i<size)sdata[tid]=g_idata[i]+g_idata[i+blockDim.x];elsesdata[tid]=0;__syncthreads();for(ints=blockDim.x/2;s>32;s>>=1){if(tid<s){sdata[tid]+=sdata[tid+s];}__syncthreads();}if(tid<32)wrapReduce5(sdata,tid);if(tid==0)g_odata[blockIdx.x]=sdata[0];}

可以看到kernel耗时进一步降低

Reduce6——for循环进一步展开

可以进一步展开kernel中的for循环

template<unsignedintblockSize>__device__voidwrapReduce(volatileint*sdata,inttid){if(blockSize>=64)sdata[tid]+=sdata[tid+32];if(blockSize>=32)sdata[tid]+=sdata[tid+16];if(blockSize>=16)sdata[tid]+=sdata[tid+8];if(blockSize>=8)sdata[tid]+=sdata[tid+4];if(blockSize>=4)sdata[tid]+=sdata[tid+2];if(blockSize>=2)sdata[tid]+=sdata[tid+1];}template<unsignedintblockSize>__global__voidreduce6(int*g_idata,int*g_odata,intsize){extern__shared__intsdata[];inttid=threadIdx.x;inti=blockIdx.x*(blockDim.x*2)+threadIdx.x;if(i<size)sdata[tid]=g_idata[i]+g_idata[i+blockDim.x];elsesdata[tid]=0;__syncthreads();// 展开循环if(blockSize>=1024){if(tid<512){sdata[tid]+=sdata[tid+512];}__syncthreads();}if(blockSize>=512){if(tid<256){sdata[tid]+=sdata[tid+256];}__syncthreads();}if(blockSize>=256){if(tid<128){sdata[tid]+=sdata[tid+128];}__syncthreads();}if(blockSize>=128){if(tid<64){sdata[tid]+=sdata[tid+64];}__syncthreads();}// Warp 级优化if(tid<32){// 强制转换为 volatile 指针传递,确保读写可见性wrapReduce<blockSize>((volatileint*)sdata,tid);}if(tid==0)g_odata[blockIdx.x]=sdata[0];}

不过耗时并没有太大变化,但是Mark Harris当时测试时有一定提升,猜测是这些年硬件有一定的迭代优化

Reduce7——网格步幅循环加载

Mark Harris文档里最后还提了一个网格步幅循环加载(Grid-Stride Loop)的规约,原理是考虑数据量很大,然后以有限的block有限的线程去做运算,就相当于每个线程处理ceil(size/gridsize)个数据

template<unsignedintblockSize>__global__voidreduce7(int*g_idata,int*g_odata,intsize){extern__shared__intsdata[];inttid=threadIdx.x;inti=blockIdx.x*(blockSize*2)+threadIdx.x;intgridSize=blockSize*2*gridDim.x;sdata[tid]=0;while(i<size){sdata[tid]+=g_idata[i];if(i+blockSize<size){// 防止越界sdata[tid]+=g_idata[i+blockSize];}i+=gridSize;}__syncthreads();// 展开循环if(blockSize>=1024){if(tid<512){sdata[tid]+=sdata[tid+512];}__syncthreads();}if(blockSize>=512){if(tid<256){sdata[tid]+=sdata[tid+256];}__syncthreads();}if(blockSize>=256){if(tid<128){sdata[tid]+=sdata[tid+128];}__syncthreads();}if(blockSize>=128){if(tid<64){sdata[tid]+=sdata[tid+64];}__syncthreads();}// Warp 级优化if(tid<32){// 强制转换为 volatile 指针传递,确保读写可见性wrapReduce<blockSize>((volatileint*)sdata,tid);}if(tid==0)g_odata[blockIdx.x]=sdata[0];}

这里我就没去调节block数了,直接和上面的kernel用的一样的block数

Reduce8——warp shuffle

现在的CUDA有个shuffle的功能支持warp间不同线程做一些数据处理,等价于把之前的取数相加赋值改成__shfl_down_sync函数调用

template<unsignedintblockSize>__global__voidreduce8(constint*__restrict__ g_idata,int*__restrict__ g_odata,intsize){extern__shared__intsdata[];unsignedinttid=threadIdx.x;unsignedintidx=blockIdx.x*(blockSize*2)+tid;intsum=0;// 处理不能整除情况if(idx<size)sum+=g_idata[idx];if(idx+blockSize<size)sum+=g_idata[idx+blockSize];sdata[tid]=sum;__syncthreads();//--------------------------------------------------// Block Reduction//--------------------------------------------------if(blockSize>=1024){if(tid<512)sdata[tid]+=sdata[tid+512];__syncthreads();}if(blockSize>=512){if(tid<256)sdata[tid]+=sdata[tid+256];__syncthreads();}if(blockSize>=256){if(tid<128)sdata[tid]+=sdata[tid+128];__syncthreads();}if(blockSize>=128){if(tid<64)sdata[tid]+=sdata[tid+64];__syncthreads();}//--------------------------------------------------// Warp Reduction using shuffle//--------------------------------------------------if(tid<32){intval=sdata[tid];if(blockSize>=64)val+=sdata[tid+32];for(intoffset=16;offset>0;offset>>=1){val+=__shfl_down_sync(0xffffffff,val,offset);}if(tid==0)g_odata[blockIdx.x]=val;}}

不过后面几种Reduce方法耗时都比较接近了

参考文献

Optimizing Parallel Reduction in CUDA

http://www.gsyq.cn/news/1495285.html

相关文章:

  • 服装货源+AI穿搭教学一站式攻略,这个车内穿搭博主藏了太多干货 - 信息热点
  • 总结速度差14倍 从14分钟缩至1分钟2026实测分钟搞定知识视频总结这个实用技
  • 2026上海GEO(AI搜索优化)公司哪家好?
  • [ZJOI2019] 开关 (视觉和谐版)
  • 台州专业的漏水检测公司推荐2026年6月最新 - 信息热点
  • OnmyojiAutoScript:阴阳师自动化脚本,每天为你节省2小时游戏时间
  • 3分钟搞定Windows和Office激活:KMS_VL_ALL_AIO智能激活脚本完全指南
  • 如何快速集成Qt PDF查看器:QPDF Widget完整指南
  • 无锡 2026 瓷砖空鼓翘边拱起原因及解决办法 免砸砖快速修复 - 苏易房屋修缮
  • 寄快递哪家便宜?寄半折寄件5折起 - 快递物流资讯
  • 嵌入式接口时序设计:从理论到i.MX 7Solo实战解析
  • UnityExplorer:Unity游戏实时调试与修改的终极免费解决方案
  • 3个关键维度评估:如何为你的时间序列预测项目选择最佳技术方案?
  • Java初学者可用的医院挂号系统完整源码(SpringBoot+MySQL+前后端分离)
  • 2026 年 6 月 Riverhawk/GMN/NHBB 轴承官方授权代理商 苏州德胜恩全国现货直供 - GrowthUME
  • 英语阅读_The mirror of friendship
  • SQL/NoSQL数据库为何成为TVA的记忆系统(3)
  • 光学实验室“祖传”MetroPro数据怎么给Zemax用?一个命令将Zygo dat转成zxg格式
  • 泉州卫浴仓储店加盟排行:5家品牌实力维度对比 - 起跑123
  • BrotatoLike游戏制作(拆解人物Player脚本)
  • Pulseaudio进阶开发之ALSA两种播放方案(二十九)
  • sqli-labs解题思路(Less-1到Less-11)
  • 终极指南:让macOS原生支持MKV、AVI等视频格式预览
  • 1-17串锂电池保护板设计包:SH367309+STM32F030硬件方案+RS485上位机调试工具
  • 团队邮件协作效率低?你可能忽略了这三个关键功能
  • MD5哈希算法:原理、应用与安全性解析
  • SSHFS-Win完整指南:Windows与Linux服务器间无缝文件访问的终极解决方案
  • C++学习实例:杨辉三角
  • 大模型多轮对话状态管理:Spring Boot中的上下文工程实践
  • 架构腐化检测:从依赖分析到架构守护的工程化实践