手撕深度学习之CUDA并行规约算法(上篇):硬核揭秘200%性能提升的GPU优化之道,从硬件特性到算法实现的完整进阶指南

news/2025/9/26 18:29:11/文章来源:https://www.cnblogs.com/qzero233/p/19114040

本文首发于本人的微信公众号,原文链接:

https://mp.weixin.qq.com/s/nzE2NB7T2U2F46IBJmPSYQ

摘要

本文为CUDA并行规约系列文章的上篇,本系列将会介绍CUDA编程的一些基础软硬件知识,然后给出7种规约算法的实现,并从硬件的角度对它们进行分析和优化,最终给出一个开箱即用的模板代码。

本文主要介绍了CUDA编程的基础软硬件知识,并给出了2种规约算法的实现和分析。

写在前面

最近在写DL Systems的Homework 3,遇到了要使用cuda来实现reduction(例如sum,max等)的任务。几年前写cuda时我也曾被这个问题难住过,于是便正好借此机会深入研究一下其中的门道。

要实现一个简单的reduction算法并不难,只需要把数据分块扔给执行线程,然后每个线程里用for循环做reduction即可,由于有GPU并行处理的助力,这一实现肯定是会比CPU上更快的。

但是如果想要更进一步地利用上GPU的能力,就需要花一些功夫来设计这个算法了。这其中涉及到了GPU的若干硬件特性,包括SIMT(Single Instruction, Multiple Threads)架构,Wrap(线程束),Bank(存储体)等。NVIDIA甚至专门出了个PPT来讲解如何在GPU上高效地完成reduction,本系列也是以这个PPT为蓝本展开的。

由于篇幅原因,本系列预计会分为上下两篇,上篇主要介绍一些前置知识,基本思想和两个比较初级的reduction算法;下篇会接着介绍另外五种升级版的reduction算法,并给出一个使用C++模板实现的,可以开箱即用的reduction算法实现。

希望本系列文章能够给大家带来帮助。在GPU并行计算方面我也是刚入门的小白,如果文章中有疏漏之处,还请大佬们批评指正。

参考资料

NVIDIA的并行计算PPT:
https://developer.download.nvidia.cn/assets/cuda/files/reduction.pdf

7 Step Optimization of Parallel Reduction with CUDA:
https://medium.com/@rimikadhara/7-step-optimization-of-parallel-reduction-with-cuda-33a3b2feafd8

关于GPU你需要知道的小知识

这一章节主要介绍一些GPU硬件层面的前置知识。CUDA算子开发是一个需要尽可能榨干硬件全部性能的工作,所以这就要求开发者对硬件有着一些基本的了解。在具备了硬件相关的基础知识后,我们就能轻松的回答如下问题:

  • 为什么仅仅是去掉一个if-else,算子性能就提升了一倍多?
  • 为什么只是改了一下元素访问顺序,计算速度就快了那么多?
  • ......

CUDA编程基础

这里再简单回顾一下CUDA编程里的一些基础概念。对这部分比较熟悉的朋友可以直接跳到下一小节。

内核函数

我们开发者会使用C++编写内核函数,然后通过在外部调用内核函数的方式来启动这个内核函数。

在启动内核后,会有若干个处理器并行地执行相同的内核函数代码。开发者可以在内核函数里通过blockIdx和threadIdx来知道当前执行的线程的编号,从而对内存中的指定区域进行操作。

例如,如下是一个经典的内核函数的例子,这个例子演示了向量的加法操作:

__global__ void VectorAddKernel(const float *a, const float *b, float *out, size_t array_size) {const size_t idx = threadIdx.x;if (idx >= array_size) {return;}out[idx] = a[idx] + b[idx];
}

由此也可以窥见CUDA编程的风格:即使用相同的代码来操作数据中不同的区域,并且内核函数不返回结果,而是把结果写入到某个地址中。

线程组织架构

紧接着上面的向量加法的例子,那么blockIdx和threadIdx是怎么来的呢?答案是由调用这个内核函数的主机函数指定的。下面是调用向量加法内核函数的主机函数的例子:

// 假设这里的a, b, out的地址都是CUDA地址
void VectorAdd(const float *a, const float *b, float *out, size_t array_size) {dim3 grid(1, 1, 1);dim3 block(array_size, 1, 1);VectorAddKernel<<<grid, block>>>(a, b, out, array_size);
}

主机函数通过传入的grid和block来指定会有多少个线程参与运算。具体而言,线程组织架构分为3级,分别是Grid, Block, Thread,其中Thread是真正做事的,若干个Thread组成一个Block,若干个Block组成Grid,目前可以认为全局只有一个Grid。

为了编程方便,我们把Grid和Block都分成了xyz三个维度,如下图所示:

image

上面代码里传入的实际上是每个维度里Block和Thread的数量。在上面的例子中,一共有\(1 \times 1 \times 1 = 1\)个Block,每个Block里有\(array\_size \times 1 \times 1 = array\_size\)个线程。

需要注意的是,虽然这里使用xyz分成了三个维度,但是在硬件层面上,所有线程都是等价的,所以这里的分维度仅仅是为了在逻辑上表示起来更方便,实际在执行时维度并不会产生影响。

不互通的内存

再回到上面的主机函数中,第一行有个注释:“假设这里的a, b, out的地址都是CUDA地址”。这就涉及到了CUDA编程中的又一个注意事项:虽然内核函数和主机函数都是写在同一个文件里的,但是实际执行时,内核函数会运行在GPU上,而主机函数还是在CPU上,所以两者的地址是不互通的。也就是说,主机函数上的一个地址,对应的数据是在内存条中的,如果不加以转换,就把这个地址传递给内核函数,那么内核函数就会尝试在显卡内存中对应位置去取数据,就会造成严重的错误。

具体的转换方法是使用cudaMalloc申请内存,然后再把主机内存中的数据copy到显卡内存中。在数据量大的时候,这也会是一个不可忽视的瓶颈。因此,pytorch等框架会让数据尽量一直放在CUDA中,以避免数据转移的开销。

SIMT模型与线程束

GPU在实际进行调度时是以一个线程束(Wrap)为单位进行调度的,一个Wrap包含32个线程,其执行过程遵循SIMT(Single Instruction, Multiple Threads)架构的设计。

具体而言,GPU在取指令后会把该指令广播给Wrap中所有的32个线程,这32个线程会同时执行该指令,然后在下一个时钟周期到来后,再重复相同的过程。所以Wrap中的线程可以认为一定是同步的,因为同一时刻每个线程都在执行相同的指令,这是硬件上保证的。

那么这时候问题就来了,如果遇到if语句,导致一部分线程和另一部分线程要执行的指令不同会发生什么呢?

答案是这时候就会发生所谓的分支发散(Wrap Divergence),在发生Wrap Divergence时,这些线程的执行会由并行转成串行,GPU会首先执行第一个分支的代码,那些满足第一个分支条件的线程会被激活,其余线程会被冻结,执行完第一个分支后,再执行第二个分支,同样冻结一部分线程,激活一部分线程,直到分支结束。

Wrap Divergence会导致严重的性能损失,因此在做CUDA开发时一定要尽可能的保证一个Wrap里所有线程都执行相同的代码。(这一点在后面具体的reduction算法部分也会涉及到)

线程同步

CUDA支持一个线程块(Block)内进行线程同步。具体而言,只需要调用__syncthreads,调用之后线程就会阻塞,直到线程块内所有线程都执行到这一点。

共享内存与Bank Conflict

我们在主机函数中使用cudaMalloc申请的内存是位于GPU板载DRAM上的全局内存,全局内存的容量大,但是访问慢。那么相对应的,也会有一块容量小,但是访问快速的内存,它就是共享内存(Shared Memory)。共享内存位于GPU的流处理器的SRAM上,其作用域仅限于Block内。

为了提升访问速度,GPU把共享内存分为了32个Bank,这里的Bank可以理解为口岸的意思,也就是把对内存的访问分摊到了32个口岸来完成,这些口岸是可以并行工作的,访问一个地址只能去该地址对应的口岸访问。

具体而言,访问地址0需要去第0个Bank,访问地址1需要去第1个Bank,访问地址31需要去第31个Bank,访问地址32则又回到了起点,需要去第0个Bank,以此类推。

理想情况下,一个Wrap里的线程会按顺序访问0,1,2...,31号地址,那么这32个访问在一个时钟周期内就能完成。但是如果一个Wrap里有2个线程访问了地址0,或者一个访问了地址0,另一个访问了地址32,就会在Bank 0上发生Bank Conflict

在发生Bank Conflict时,由于GPU无法在一个时钟周期内同时处理这两个互相冲突的访问请求,所以就只能把这两个请求串行化,导致整个Wrap的执行节奏被拖慢。最坏情况下,如果32个线程都访问同一个Bank,那么这个Wrap就需要花费原来32倍的时间来执行这个访存指令。

Bank Conflict同样会导致严重的性能损失,这也是我们在开发时需要注意的一点,这一点在后面具体的reduction算法实现中也会提及到。

问题定义

这里先严格定义一下我们要解决的问题:我们有一个矩阵\(X \in \mathbb{R}^{m \times n}\),一个规约函数\(Reduce(x_1, x_2, \ldots, x_n)\)。我们需要求一个向量\(O \in \mathbb{R}^{m}\),满足\(O_i = Reduce(x_{i1}, x_{i2}, \ldots, x_{in})\)

特别的,我们还要求Reduce函数满足如下性质:\(Reduce(x_1, x_2, x_3) = Reduce(Reduce(x_1, x_2), x_3)\)

举个例子:这个Reduce函数可以是求和函数Sum,为了便于理解,后面我们就直接以Sum为例展开了。

这个问题换一种描述就是:输入\(m \times n\)的矩阵,然后对每一行进行求和,输出求和之后的结果。

需要注意的是,在具体实现中,我们会把矩阵X按行优先的方式展开成一个\(m \times n\)的一维向量。因为在C++中实现多维数组比较麻烦,一般在底层都是使用一维数组+维度信息的方式来表示一个多维数组的。

算法基本思想

我们可以认为是有m个batch,每个batch里的n个元素做Sum操作。因此可以开m个Block,每个Block里有n个Thread。由于每个Block里的操作都相同,所以我们就把视角聚焦到某个具体的Block。

算法大致的流程为:首先n个线程会把n个数据复制到共享内存中,每个线程都对应和负责共享内存中的一个位置,然后再做类似于从下至上的归并排序的操作,即\(T_0\)\(A_0\)\(A_1\)的数值相加,然后存储到\(A_0\)中;\(T_2\)\(A_2\)\(A_3\)的值相加,存到\(A_2\)中,以此类推(注:这里\(T_0\)表示编号为0的线程,\(A_0\)表示共享内存中编号为0的元素)。

至于\(T_1\), \(T_3\)这些线程,它们不会做任何事。

通过块内的线程同步机制(__syncthreads)确保所有线程都执行完成后,就进入下一轮归并,此时就是\(T_0\)\(A_0\)\(A_2\)的数值相加,存到\(T_0\)\(T_4\)\(A_4\)\(A_6\)相加,存到\(A_4\)......。而其余线程,如\(T_1\), \(T_2\), \(T_3\),则什么也不做。

如此循环下去,直到最后步长为n,此时\(A_0\)存储的就是全部n个数的和,此时由\(T_0\)\(A_0\)写入到输出向量的对应位置即可完成任务。

算法具体实现(上篇)

为了便于讨论,这里就取n=256,即n是2的整数次幂。这里其实有一个open question:即在实际实现时是否需要考虑到n不是2的整数次幂的情况,做边界条件的处理?这里个人认为不应该考虑边界条件,应该由上层来做padding来保证n一定是2的整数次幂。因为如果需要考虑边界条件,就不可避免的会引入很多if-else,这会增大触发Wrap Divergence的概率,比如偶尔会需要对奇数值做一些修正等等。

具体的padding方式需要看Reduce函数是什么,如果是Sum,可以直接填0,如果是Max,可以考虑填-inf等。

总之,个人的观点是:CUDA内核函数不应该过多的处理边界条件,边界条件的处理应该尽可能由上层来完成

V0版本:Interleaved Addressing

这一版本的算法就是上面算法基本思想部分的实现,具体的内核函数代码如下图所示:

image

具体的执行过程可以通过下面这张图看出:

image

\(T_0\)的视角来看,第一个循环里,计算\(A_0 = A_0 + A_1\),第二个循环里,计算\(A_0 = A_0 + A_2\),由于此时在上一轮中,\(T_2\)已经完成了\(A_2 = A_2 + A_3\),所以这一轮循环结束时,\(A_0\)就已经完成了前4个元素的加和。

在能够确保n为2的整数次幂的情况下,整个算法实现就会非常简单,只需要一路把stride *= 2即可,无需考虑任何边界条件。

V0版本的不足之处

V0版本主要有2个问题:

  1. 取模操作非常耗时,因为除法操作在硬件底层实现就是会慢一些
  2. Wrap Divergence:我们可以注意到,在第一轮循环中,\(T_0\)需要执行加和操作,所以是走的if里的true分支,而\(T_1\)不需要任何操作,所以走的是false分支,由于两者位于同一个Wrap里,所以此时已经造成了Wrap Divergence,这会严重影响执行性能。

V1版本:Interleaved Addressing(改进版)

有一个可以同时修复掉取模和Wrap Divergence的方法,具体修复方法如下:

image

这一算法的主要思想是:从后往前让线程停止工作。具体而言,以n=256为例,在第一次循环时,只有\(T_0\)\(T_{127}\)在工作,而\(T_{128}\)\(T_{255}\)会走到if的false分支,什么也不做;在第二次循环时,\(T_{64}\)\(T_{127}\)又会走到false分支,以此类推。更具体的可以看下面这张图

image

这一算法保证了在工作的线程和在休息的线程始终是扎堆的,从而避免了一开始就Wrap Divergence的局面。

NVIDIA也在PPT里面展示了这一优化的效果,如下图所示,还是相当可观的:

image

V1版本的不足之处

首先,这个方法肯定是没有彻底解决Wrap Divergence的,毕竟到最后就只有\(T_0\)在工作了。当然,这也是一个优化方向,这个后面会提及到。

其次,这一方法还有可能导致Bank Conflict。观察\(T_0\)\(T_1\)在每次循环中的访问内存地址,可以发现两者差距越来越大,并最终会在某一时刻,\(T_1\)要计算\(A_{32} = A_{32} + \ldots\),而此时\(T_0\)也要访问\(A_0\),这就导致了Bank Conflict,因为两者都同时想要访问Bank 0。

并且更糟糕的是,此时\(T_0\)的Wrap里的所有线程都会访问Bank 0,这是Bank Conflict里最糟的情况,会导致Wrap内的并行完全变成串行。

至于这一问题的解决办法,还请听下回分解啦。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/918664.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

网络运营者中国seo第一人

1、ORACLE快速遍历树 2、join基表很大&#xff0c;性能问题 转载于:https://www.cnblogs.com/stevenlii/p/8631708.html

实战需求分析

需求获取的方法 1.制作调查问卷 2.单据分析(单据时客户填写的纸质单据) 3.报表分析(报表时客户对产品的反应的各种数据的整理) 如何分析报表:使用常识判断、听客户讲解、研习客户文档、研习电子表格公式

完整教程:实战:基于 BRPC+Etcd 打造轻量级 RPC 服务——高级特性与生产环境深度实践

完整教程:实战:基于 BRPC+Etcd 打造轻量级 RPC 服务——高级特性与生产环境深度实践pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; …

哪里可以做网站优化网站怎么做配置文件夹

首先我们需要下载ssh&#xff0c;因为我们没有安装 sshd 命令意思是开启ssh 下载完以后要设置密码&#xff0c;我设置得是 123456 开启服务&#xff0c;查看ip 电脑连接 ssh 刚刚得ip -p 8022 后面就连接上了 我可以在这里启动我手机上的vnc

广州网站建设 seo怎么用ps做网站上的产品图

概述 实现规范化、标准化的引导式设计&#xff0c;以业务需求为输入&#xff0c;识别业务特点&#xff0c;并通过引导式设计&#xff0c;找到最适合的设计模式、具体方案&#xff0c;汇总成为应用的设计&#xff0c;拉齐各应用的设计一的致性。 采用标准化的方式开展设计…

数学草稿

P13645 Totient with Divisors \[\begin{aligned} \sum_{i=1}^n\sum_{j=1}^m\varphi(i)\varphi(j)\sigma(ij)&=\sum_{i=1}^n\sum_{j=1}^m\varphi(i)\varphi(j)\sum_{a|i}\sum_{b|j}\frac{ib}{a}\times[a\perp b]\\…

【RabbitMQ】主题(Topics)与主题交换机(Topic Exchange)

本章目标理解主题交换机(Topic Exchange)的强大路由能力。掌握通配符*和#的使用规则。学习基于模式匹配的复杂消息路由。实现一个支持多维度过滤的智能消息系统。一、理论部分 1. 主题交换机(Topic Exchange)简介 …

企业网站推广技巧有哪些怎样做免费网站推广

1.你说一下什么是分布式锁 分布式锁是一种在分布式系统环境下实现的锁机制&#xff0c;它主要用于解决&#xff0c;多个分布式节点之间对共享资源的互斥访问问题&#xff0c;确保在分布式系统中&#xff0c;即使存在有多个不同节点上的进程或线程&#xff0c;同一时刻也只有一…

详细介绍:八股已死、场景当立(微服务保护篇)

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

Ubuntu上编译 Linux_RT 内核

目录一、编译安装1. 下载 Linux 内核源码和对应版本的 preempt_rt 补丁源码2. 解压及安装依赖项2.1 安装依赖2.2 解压缩文件并打补丁3. 自定义部分编译配置3.1 生成相关的内核配置文件3.2 修改调整内核的一些配置项4. …

做淘宝的网站的多少钱开发app的过程

热门推荐 &#xff08;1&#xff09;即将直播持续集成与交付&#xff1a;分层自动化之UI自动化体系建设直播简介&#xff1a;本系列直播由阿里旗下一站式研发提效平台云效策划推出&#xff0c;主要为大家详细介绍阿里巴巴在持续集成和持续交付的最佳实践。 直播讲师&#xff1a…

vue3 + vite Cannot access ‘xxx‘ before initialization

vue3 + vite Cannot access ‘xxx‘ before initialization 是用于循环引用造成的,不建议循环引用,所以遇到这种情况要优化代码

《“悬荡”于理想与现实之间:一份关于人机共生未来的思想实验评估》

《“悬荡”于理想与现实之间:一份关于人机共生未来的思想实验评估》 对这篇《元人文AI:价值共生时代的技术哲学与创新实践》的分析是否客观,需要从多个维度进行综合评估。总的来说,该分析在理论构建的深度、体系的…

区别:RS-232、RS-422、RS-485

RS-232、RS-422、RS-485博客园文作者:Citrusliu博文地址:https://www.cnblogs.com/citrus

解决字符串数组中大整数精度问题

示例:[{"specId": 3140724743078936585, "quantity": 1, "specName": "箱"}, {"specId": 3140724798770905093, "quantity": 10, "specName"…

软文发布门户网站太原seo霸屏

“八股文”在实际工作中是助力、阻力还是空谈&#xff1f; 作为现在各类大中小企业面试程序员时的必问内容&#xff0c;“八股文”似乎是很重要的存在。但“八股文”是否能在实际工作中发挥它“敲门砖”应有的作用呢&#xff1f;有IT人士不禁发出疑问&#xff1a;程序员面试考…

playwright-mcp入门

npm install -g @executeautomation/playwright-mcp-server npm install -g @playwright/mcp 配置-方式1 npx @playwright/mcp@latest --port 8931{"mcpServers": {"playwright": {"url"…

【征文计划】深度剖析 Rokid SLAM 算法:从传感器融合到空间重建的完整技术链路 - 实践

【征文计划】深度剖析 Rokid SLAM 算法:从传感器融合到空间重建的完整技术链路 - 实践pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important;…

国信DRS数据恢复中心成为东芝(TOSHIBA)存储硬盘的数据恢复合作服务商

国信DRS数据恢复中心可在不影响原厂硬件质保的情况下打开密封的硬盘驱动器以便恢复数据,东芝硬盘用户可享有我中心数据恢复服务20%的折扣优惠。 如果您的硬盘驱动器硬件损坏无法正常识别或读取异常,数据误删除、误分…

深入解析Windows注册表regf文件格式

本文详细解析了Windows注册表使用的regf二进制文件格式,涵盖基础块、存储桶和单元格结构,探讨了安全描述符、子键索引等关键组件的实现细节及其在历史漏洞中的作用,为安全研究人员提供深入的技术参考。Windows注册表…