一、第一章
基础,暂时略过。
二、第二章:CUDA Programming Model
记录第二章的一些要点。
首先2.1主要讲了如何编写一个cuda程序,它包括了主程序和内核程序,以及错误处理等内容。
2.2节讲了如何给内核函数计时,一种方法是CPU提供的time工具,第二种是cuda自身的nvprof工具。2.3节讲了如何组织线程进行矩阵求和运算,通过不同的线程网格和线程块的配置,可以得到不同的性能。
1. 二维网格和二维块对矩阵求和情况:当线程块配置为(32,32)的时候,(32,16)的性能几乎是前者的两倍,但是(16,16)的性能却不如(32,16)但是好于(32,32)。
2. 一维网格和一维块对矩阵求和情况:每个线程计算一列数据,线程块配置为(32,1)时,性能显示与(32,32)的线程配置基本相同,增加线程块的大小为(128,1),显示结果快了一些。
3. 二维网格和一维块对矩阵求和的情况:每个线程计算一个数据,当线程块配置为(256,1)时,表示出最佳性能。
2.4节有关设备管理部分:CUDA运行时API函数和NVIDIA系统管理界面(nvidia-smi)命令行实用程序。例如函数cudaError_t cudaGetDeviceProperties(cudaDeviceProp* prop, int device); 在命令行可以使用nvidia-smi -L来显示全部设备信息。
三、第三章:CUDA Execution Model
3.1 INTRODUCING THE CUDA EXECUTION MODEL
CUDA执行模型:从GPU物理计算架构方面理解和优化程序
通常,执行模型提供指令如何在特定计算架构上执行的操作视图。 CUDA 执行模型公开了 GPU 并行架构的抽象视图,允许推理线程并发性。
CUDA 编程模型公开了两个主要抽象:内存层次结构和线程层次结构,它们允许控制大规模并行 GPU。 因此,CUDA 执行模型有助于在指令吞吐量和内存访问方面编写高效代码。
3.1.1 GPU Architecture Overview
Streaming Multiprocessors (SM) 的组成部分(Fermi SM)
-CUDA Cores
-Shared Memory/L1 Cache-Register File
-Load/Store Units
-Special Function Units-Warp Scheduler
GPU 中的每个 SM 旨在支持数百个线程的并发执行,并且每个 GPU 通常有多个 SM,因此有可能在单个 GPU 上同时执行数千个线程。
当内核网格启动时,该内核网格的线程块分布在可用的 SM 中以供执行。 一旦在 SM 上调度,线程块的线程仅在分配的 SM 上并发执行。 多个线程块可以一次分配给同一个 SM,并根据 SM 资源的可用性进行调度。
除了在 CUDA 中已经熟悉的线程级并行性之外,单个线程内的指令被流水线化以利用指令级并行性。(计算机体系结构知识)
CUDA 采用单指令多线程 (SIMT) 架构来管理和执行 32 个一组的线程,称为 warp。 warp 中的所有线程同时执行相同的指令。 每个线程都有自己的指令地址计数器和寄存器状态,并对自己的数据执行当前指令。 每个 SM 将分配给它的线程块划分为 32 个线程的 warp,然后安排在可用的硬件资源上执行。
SIMD 要求一个向量中的所有向量元素在一个统一的同步组中一起执行,而 SIMT 允许同一个 warp 中的多个线程独立执行。? 即使 warp 中的所有线程都从相同的程序地址开始,单个线程也可能有不同的行为。 SIMT 使您能够为独立的标量线程编写线程级并行代码,并为协调线程编写数据并行代码。 SIMT 模型包括 SIMD 不具备的三个关键特性:
Each thread has its own instruction address counter.
Each thread has its own register state.
Each thread can have an independent execution path.
从概念上讲,32个线程可以视为 SM 以 SIMD 方式同时处理的工作粒度。
一个线程块只被调度到一个SM上。 一旦在 SM 上安排了一个线程块,它就会保留在那里直到执行完成。 一个 SM 可以同时持有多个线程块。 图 3-2 从 CUDA 编程的逻辑视图和硬件视图说明了相应的组件。
共享内存和寄存器是 SM 中的宝贵资源。 共享内存在驻留在 SM 上的线程块之间进行分区,而寄存器在线程之间进行分区。 线程块中的线程可以通过这些资源相互协作和通信。 虽然线程块中的所有线程在逻辑上并行运行,但并非所有线程都可以在物理上同时执行。 因此,线程块中的不同线程可能会以不同的速度执行。
在并行线程之间共享数据可能会导致竞争条件:多个线程以未定义的顺序访问相同的数据,这会导致不可预测的程序行为。 CUDA 提供了一种在线程块内同步线程的方法,以确保所有线程在继续执行之前达到执行中的特定点。 然而,没有提供用于块间同步的原语。
虽然可以按任何顺序安排线程块内的 warp,但活动 warp 的数量受 SM 资源限制。 当一个 warp 因任何原因空闲时(例如,等待从设备内存中读取值),SM 可以自由地从驻留在同一 SM 上的任何线程块调度另一个可用的 warp。 在并发 warp 之间切换没有开销,因为硬件资源在 SM 上的所有线程和块之间进行分区,因此新调度的 warp 的状态已经存储在 SM 上。
SM:GPU 架构的核心
流式多处理器 (SM) 是 GPU 架构的核心。 寄存器和共享内存是 SM 中的稀缺资源。 CUDA 在驻留在 SM 上的所有线程之间划分这些资源。 因此,这些有限的资源对 SM 中活动 warp 的数量施加了严格的限制,这对应于 SM 中可能的并行度。 了解有关 SM 硬件组件的一些基本事实将帮助您组织线程和配置内核执行以获得最佳性能。
在下一节中,将简要了解两种不同版本的 NVIDIA GPU 架构:Fermi 和 Kepler 架构。 将特别注意他们的硬件资源。 将通过示例和练习了解它们的硬件特性,这将帮助深入了解如何提高内核性能。
3.1.2 The Fermi Architecture
图 3-3 展示了 GPU 计算的 Fermi 架构的逻辑框图,其中大部分省略了特定于图形的组件。 Fermi 具有多达 512 个加速器内核,称为 CUDA 内核。 每个 CUDA 内核都有一个全流水线整数算术逻辑单元 (ALU) 和一个浮点单元 (FPU),每个时钟周期执行一个整数或浮点指令。 CUDA 核心被组织成 16 个流式多处理器 (SM),每个处理器有 32 个 CUDA 核心。 Fermi 有六个 384 位 GDDR5 DRAM 内存接口,支持总共高达 6 GB 的全局板载内存,这是许多应用程序的关键计算资源。 主机接口通过 PCI Express 总线将 GPU 连接到 CPU。 GigaThread 引擎(在图表左侧以橙色显示)是一个全局调度程序,可将线程块分发到 SM warp 调度程序。
Fermi 包括一个连贯的 768 KB L2 缓存,由所有 16 个 SM 共享。 图 3-3 中的每个 SM 由一个垂直矩形表示,其中包含:
-执行单元(CUDA 内核)
-调度 warp 的调度器和调度器单元
-共享内存、寄存器文件和 L1 缓存
每个多处理器有 16 个加载/存储单元(如图 3-1 所示),允许每个时钟周期为 16 个线程(半个warp)计算源地址和目标地址。 特殊功能单元 (SFU) 执行内部指令,例如正弦、余弦、平方根和插值。 每个 SFU 可以在每个时钟周期的每个线程中执行一条内部指令。
每个 SM 都有两个 warp 调度器和两个指令调度单元。 当一个线程块被分配给一个 SM 时,线程块中的所有线程都被划分为 warp。 两个 warp 调度程序选择两个 warp,并从每个 warp 向一组 16 个 CUDA 内核、16 个加载/存储单元或 4 个特殊功能单元发出一条指令(如图 3-4 所示)。 Fermi 架构,计算能力 2.x,可以同时处理每个 SM 的 48 个 warp,一次驻留在单个 SM 中的总共 1,536 个线程。
Fermi 的一个关键特性是 64 KB 片上可配置内存,它在共享内存和 L1 缓存之间进行分区。 对于许多高性能应用程序,共享内存是提高性能的关键因素。 共享内存允许块内的线程协作,促进片上数据的广泛重用,并大大减少片外流量。 CUDA 提供了一个运行时 API,可用于调整共享内存和 L1 缓存的数量。 修改片上内存配置可以提高性能,具体取决于给定内核中共享内存或缓存的使用情况。 第 4 章和第 5 章将更详细地介绍该主题。
Fermi 还支持并发内核执行:从同一应用程序上下文启动的多个内核同时在同一 GPU 上执行。 并发内核执行允许执行多个小内核的程序充分利用 GPU,如图 3-5 所示。 Fermi 允许最多 16 个内核同时在设备上运行。 从程序员的角度来看,并发内核执行使 GPU 看起来更像是 MIMD 架构。
3.1.3 The Kepler Architecture
图 3-6 给出了 Kepler K20X 芯片框图,包含 15 个流式多处理器 (SM) 和 6 个 64 位内存控制器。
开普勒架构的三个重要创新是:
- Enhanced SMs
- Dynamic Parallelism
- Hyper-Q
Kepler K20X 的核心是一个新的 SM 单元,它包含多项架构创新,可提高可编程性和电源效率。 每个 Kepler SM 单元由 192 个单精度 CUDA 内核、64 个双精度单元、32 个特殊功能单元(SFU)和 32 个加载/存储单元(LD/ST)组成(如图 3-7 所示)。
每个 Kepler SM 包括四个 warp 调度器和八个指令调度器,使四个 warp 可以在单个 SM 上发布和并发执行。 Kepler K20X 架构(计算能力 3.5)可以为每个 SM 调度 64 个 warp,一次在单个 SM 中总共驻留 2,048 个线程。 与 Fermi 上的 32K 相比,K20X 架构将寄存器文件大小增加到 64K。 K20X 还允许在共享内存和 L1 缓存之间对片上内存进行更多分区。 与 Fermi 设计相比,K20X 能够提供超过 1 TFlop 的峰值双精度计算能力,电源效率提高 80%,每瓦性能提高 3 倍。
动态并行是 Kepler GPU 引入的一项新功能,它允许 GPU 动态启动新网格。 有了这个特性,任何内核都可以启动另一个内核并管理正确执行额外工作所需的任何内核间依赖关系。 此功能使您可以更轻松地创建和优化递归和数据相关的执行模式。 如图 3-8 所示,在没有动态并行的情况下,主机启动 GPU 上的每个内核; 通过动态并行,GPU 可以启动嵌套内核,无需与 CPU 通信。 动态并行性拓宽了 GPU 在各个学科中的适用性。 如果以前这样做成本太高,您可以动态启动中小型并行工作负载。
Hyper-Q 在 CPU 和 GPU 之间增加了更多的同步硬件连接,使 CPU 内核能够同时在 GPU 上运行更多任务。 因此,在使用 Kepler GPU 时,您可以预期提高 GPU 利用率并减少 CPU 空闲时间。 Fermi GPU 依靠单个硬件工作队列将任务从 CPU 传递到 GPU,这可能会导致单个任务阻塞了当前队列中位于它后面的所有其他任务,使其无法取得进展。 Kepler Hyper-Q 消除了这个限制。 如图 3-9 所示,Kepler GPU 在主机和 GPU 之间提供了 32 个硬件工作队列。 Hyper-Q 可在 GPU 上实现更高的并发性,从而最大限度地提高 GPU 利用率并提高整体性能。
不同架构计算能力比较:
3.1.4 Profile-Driven Optimization
性能分析工具深入洞察内核的性能,检测核函数中影响性能的瓶颈。CUDA提供了两 个主要的性能分析工具:nvvp,独立的可视化分析器;nvprof,命令行分析器。
nvvp是可视化分析器,它可以可视化并优化CUDA程序的性能。这个工具会显示CPU 与GPU上的程序活动的时间表,从而找到可以改善性能的机会。此外,nvvp可以分析应用 程序潜在的性能瓶颈,并给出建议以消除或减少这些瓶颈。该工具既可作为一个独立的应 用程序,也可作为Nsight Eclipse Edition(nsight)的一部分。
nvprof在命令行上收集和显示分析数据。nvprof是和CUDA 5一起发布的,它是从一个 旧的命令行CUDA分析工具进化而来的。跟nvvp一样,它可以获得CPU与GPU上CUDA关 联活动的时间表,其中包括内核执行、内存传输和CUDA的API调用。它也可以获得硬件 计数器和CUDA内核的性能指标。
有3种常见的限制内核性能的因素:
·存储带宽
·计算资源
·指令和内存延迟
后续我们会分析这些因素。
3.2 UNDERSTANDING THE NATURE OF WARP EXECUTION
在内核中似乎所有的线程都是并行地运行的。在逻辑上这是正确的,但从硬件的角度来看,不是所有线程在物理上都可以同时并行地执行。
3.2.1 线程块和线程束
编程时,线程块可以被配置为一 维、二维或三维的,然而,从硬件的角度来看,所有的线程都被组织成了一维的。在一个块中,每个线程都有一个唯一的ID。对于一维的线程块,唯一 的线程ID被存储在CUDA的内置变量threadIdx.x中,并且,threadIdx.x中拥有连续值的线程被分组到线程束中。例如,一个有128个线程的一维线程块被组织到4个线程束里。
用x维度作为最内层的维度,y维度作为第二个维度,z作为最外层的维度,则二维或三维线程块的逻辑布局可以转化为一维物理布局。
如果线程块的大小不是线程束大小的偶数倍,那么在最后的线程束里有些线程就 不会活跃。即使这些线程未被使用,它们仍然消耗SM的资源,如 寄存器。
3.2.2 线程束分化
假设在一个线程束中有16个线程执行这段代码,cond为true,但对于其他16个来说 cond为false。一半的线程束需要执行if语句块中的指令,而另一半需要执行else语句块中的 指令。在同一线程束中的线程执行不同的指令,被称为线程束分化。
注意,线程束分化只发生在同一个线程束中。在不同的线程束中,不同的条件值不会 引起线程束分化。
解决方法:
如果使用线程束方法(而不是线程方法)来交叉存取数据,可以避免线程束分化,并 且设备的利用率可达到100%。条件(tid/warpSize)%2==0使分支粒度是线程束大小的倍 数;偶数编号的线程执行if子句,奇数编号的线程执行else子句。这个核函数产生相同的输出,但是顺序不同。
tips:因为在设备上第一次运行可能会增加间接开销,并且在 此处测量的性能是非常精细的,所以,添加了一个额外的内核启动(warmingup,与 mathKernel2一样)来去除这一间接开销。
检查分支:nvprof --metrics branch_efficiency
编译器内部优化,重写核函数:
这里有点不好理解,我觉得他的意思是:这样的话就没有分化了,用一个ipred变量来断定。
3.2.2 资源分配
线程束的本地执行上下文主要由以下资源组成:
·程序计数器
·寄存器
·共享内存
每个线程块和线程束是需要进行资源分配的:
每个SM都有32位的寄存器组,它存储在寄存器文件中,并且可以在线程中进行分 配,同时固定数量的共享内存用来在线程块中进行分配。对于一个给定的内核,同时存在 于同一个SM中的线程块和线程束的数量取决于在SM中可用的且内核所需的寄存器和共享 内存的数量。
若每个线程消耗的寄存器越多,则可以放在一个SM中的线程束就越 少。如果可以减少内核消耗寄存器的数量,那么就可以同时处理更多的线程束。若一个线程块消耗的共享内存越多,则在一个SM中可以被同时处理的线程块就会 变少。如果每个线程块使用的共享内存数量变少,那么可以同时处理更多的线程块。
寄存器以线程为单位,共享内存以线程块为单位
资源可用性通常会限制SM中常驻线程块的数量。每个SM中寄存器和共享内存的数量 因设备拥有不同的计算能力而不同。如果每个SM没有足够的寄存器或共享内存去处理至 少一个块,那么内核将无法启动。
当计算资源(如寄存器和共享内存)已分配给线程块时,线程块被称为活跃的块。它 所包含的线程束被称为活跃的线程束。活跃的线程束可以进一步被分为以下3种类型:
·选定的线程束 ·阻塞的线程束 ·符合条件的线程束
Selected warp / Stalled warp / Eligible warp
如果同时满足以下两个条件则线程束符合执行条件。
·32个CUDA核心可用于执行
·当前指令中所有的参数都已就绪
例如,Kepler SM上活跃的线程束数量,从启动到完成在任何时候都必须小于或等于 64个并发线程束的架构限度。在任何周期中,选定的线程束数量都小于或等于4。如果线 程束阻塞,线程束调度器会令一个符合条件的线程束代替它去执行。由于计算资源是在线 程束之间进行分配的,而且在线程束的整个生存期中都保持在芯片内,因此线程束上下文的切换是非常快的。
在CUDA编程中需要特别关注计算资源分配:计算资源限制了活跃的线程束的数量。 因此必须了解由硬件产生的限制和内核用到的资源。为了最大程度地利用GPU,需要最大 化活跃的线程束数量。
3.2.4 延迟隐藏
何谓延迟隐藏:
Full compute resource utilization is achieved when all warp schedulers have an eligible warp at every clock cycle.
This ensures that the latency of each instruction can be hidden by issuing other instructions in other resident warps.
当每个时钟周期中所 有的线程调度器都有一个符合条件的线程束时,可以达到计算资源的完全利用。这就可以 保证,通过在其他常驻线程束中发布其他指令,可以隐藏每个指令的延迟。
GPU的指令延迟被其他线程束的计算隐藏。
考虑到指令延迟,指令可以被分为两种基本类型:
·算术指令 ·内存指令
图3-15表示线程束0阻塞执行流水线的一个示例。线程束调度器选取其他线程束执 行,当线程束0符合条件时再执行它。(以线程束为单位)
区别:带宽通常是指理论峰值,而吞吐量是指已达到的值。
这里说了这么多,最好理解的就是这句话:例如,Fermi有32个单精度浮点流水线线路,一个算术指令的延迟是20个周 期,所以,每个SM至少需要有32×20=640个线程使设备处于忙碌状态。然而,这只是一 个下边界。
3.2.5 占用率
占用率是每个 SM中活跃的线程束占最大线程束数量的比值。每个SM中线程数量的最大值在以下变量中返回: maxThreadsPerMultiProcessor
Tesla M2070的输出结果显示如下。每个SM中线程数量的最大值是1536。因此,每个 SM中线程束数量的最大值是48。
为了提高占用率,还需要调整线程块配置或重新调整资源的使用情况,以允许更多的线程束同时处于活跃状态和提高计算资源的利用率。极端地操纵线程块会限制资源的利 用:
·小线程块:每个块中线程太少,会在所有资源被充分利用之前导致硬件达到每个SM 的线程束数量的限制。
·大线程块:每个块中有太多的线程,会导致在每个SM中每个线程可用的硬件资源较少。
使用这些准则可以使应用程序适用于当前和将来的设备:
·保持每个块中线程数量是线程束大小(32)的倍数
·避免块太小:每个块至少要有128或256个线程 ·根据内核资源的需求调整块大小
·块的数量要远远多于SM的数量,从而在设备中可以显示有足够的并行
·通过实验得到最佳执行配置和资源使用情况
3.2.6 同步
在CUDA中,同步可以在
两个级别执行:
·系统级:等待主机和设备完成所有的工作
·块级:在设备执行过程中等待一个线程块中所有线程到达同一点
cudaError_t cudaDeviceSynchronize(void);
__device__ void __syncthreads(void);
当__syncthreads被调用时,在同一个线程块中每个线程都必须等待直至该线程块中所 有其他线程都已经达到这个同步点。在栅栏之前所有线程产生的所有全局内存和共享内存 访问,将会在栅栏后对线程块中所有其他的线程可见。该函数可以协调同一个块中线程之 间的通信,但它强制线程束空闲,从而可能对性能产生负面影响。
在不同的块之间没有线程同步。块间同步,唯一安全的方法是在每个内核执行结束端 使用全局同步点;也就是说,在全局同步之后,终止当前的核函数,开始执行新的核函 数。
例如下面代码中,每个线程块计算了数组的部分和,并将这些部分和存储在共享内存中。然后,通过调用__syncthreads()
实现了线程块内的同步。最后,通过对线程块内的部分和进行归约(reduction)操作,得到了每个线程块的总和,并将结果存储在输出数组中。主机代码使用全局同步来确保所有线程块都完成后再进行下一步操作,从而得到最终的总和。
#include <stdio.h>
__global__ void sumArray(int *input, int *output, int size) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
int sum = 0;
// Compute partial sum within the thread block
for (int i = tid; i < size; i += blockDim.x * gridDim.x) {
sum += input[i];
}
// Store the partial sum in shared memory
__shared__ int partialSum[256];
partialSum[threadIdx.x] = sum;
// Perform thread block level synchronization
__syncthreads();
// Perform reduction within the thread block
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) {
partialSum[threadIdx.x] += partialSum[threadIdx.x + s];
}
__syncthreads();
}
// Store the result in global memory
if (threadIdx.x == 0) {
output[blockIdx.x] = partialSum[0];
}
}
int main() {
int size = 1024; // Size of the array
int numBlocks = 4; // Number of thread blocks
int blockSize = 256; // Threads per block
int *d_input, *d_output;
int input[size], output[numBlocks];
// Initialize input array and allocate device memory
for (int i = 0; i < size; ++i) {
input[i] = i + 1;
}
cudaMalloc((void**)&d_input, size * sizeof(int));
cudaMalloc((void**)&d_output, numBlocks * sizeof(int));
// Copy input array to device
cudaMemcpy(d_input, input, size * sizeof(int), cudaMemcpyHostToDevice);
// Launch kernel
sumArray<<<numBlocks, blockSize>>>(d_input, d_output, size);
// Copy output array back to host
cudaMemcpy(output, d_output, numBlocks * sizeof(int), cudaMemcpyDeviceToHost);
// Calculate the final sum using the output array
int finalSum = 0;
for (int i = 0; i < numBlocks; ++i) {
finalSum += output[i];
}
// Print the final sum
printf("Sum: %d\n", finalSum);
// Free device memory
cudaFree(d_input);
cudaFree(d_output);
return 0;
}
不同块中的线程不允许相互同步,因此GPU可以以任意顺序执行块。这使得CUDA程 序在大规模并行GPU上是可扩展的。
3.2.7 可扩展性
可扩展性意味着为并行应 用程序提供了额外的硬件资源,相对于增加的资源,并行应用程序会产生加速。
CUDA内核启动时,线程块分布在多个SM中。网格中的线程块以并行或连续或任意 的顺序被执行。这种独立性使得CUDA程序在任意数量的计算核心间可以扩展。
3.3 并行性的表现
这一部分主要讲了对于矩阵加法,用不同的块配置,检测和比较性能表现,主要指标有:活跃线程束的占有率,全局吞吐量,全局加载效率,只优化某个指标并不能得到最好的性能,例如下面提到的16x16的块,它线程块很多导致SM占有率高,并且全局加载吞吐量也很高,但是时间却不是最好的,因为全局加载效率很低。只有当指标处在一个均衡点的时候,性能才是最好的(256,1)。
3.3.1 用nvprof检测活跃的线程束
首先使用大小为(32,32),(32,16),(16,32)和(16,16)的线程块来进行配置和检测,grid大小是根据块大小来决定的(矩阵大小是确定的),结果如下:
猜想是第二种比第一种配置的线程束占有率更高,一个内核的可实现占用率被定义为:每周期内活跃线程束的平均数量与一个SM支持的线程束最大数量的比值。用nvprof和achieved_occupancy指标来验证占有率。
因为第二种情况中的块数比第一种情况的多,所以设备就可以有更多活跃的线程束。其原因可能是第二种情况与第一种情况相比有更高的可实现占用率和更好的性能。
·第四种情况有最高的可实现占用率,但它不是最快的,因此,更高的占用率并不一定意味着有更高的性能。肯定有其他因素限制GPU的性能。
当线程块(thread block)的大小较小时,一个多处理器上可以同时调度和执行更多的线程块。每个多处理器都有一定数量的资源(寄存器、共享内存等)可供线程块使用。当线程块的大小较小时,每个线程块所需的资源较少,多处理器可以容纳更多的线程块并同时执行它们。
3.3.2 用nvprof检测内存操作
在sumMatrix内核(C[idx]=A[idx]+B[idx])中有3个内存操作:两个内存加载和一个内存存储。
用nvprof 和 gld_throughput指标检测。
第四种情况中的加载吞吐量最高,第二种情况中的加载吞吐量大约是第四种情况的一半,但第四种情况却比第二种情况慢。所以,更高的加载吞吐量并不一定意味着更高的性能。这与全局内存的内存事务有关系。
接下来用gld_efficiency(用gld_efficiency指标检测全局加载效率,即被请求的全局加载吞吐量占所需的全局加载吞吐量的比值。)
两种情况下的加载效率是最前面两种情况的一半。这可以解释 为什么最后两种情况下更高的加载吞吐量和可实现占用率没有产生较好的性能。尽管在最后两种情况下正在执行的加载数量(即吞吐量)很多,但是那些加载的有效性(即效率) 是较低的。
注意,最后两种情况的共同特征是它们在最内层维数中块的大小是线程束的一半。对网格和块启发式算法来说,最内层的维数应该总是线程束大小的倍数。之后讨论半个线程束大小的线程块是如何影响性能
3.3.3 增大并行性
最内存维度的大小影响性能,所以可以往更大的范围去测试:
最后一次的执行配置块的大小为(256,8),这是无效的。一个块中线程总数超过了1024个(这是GPU的硬件限制)。
最好的结果是第四种情况,块大小为(128,2),时间是0.032688。与第一种情况中块大小为(64,2)相比,尽管在这种情况下启动的线程块最多,但不是最快的配置。
因为第二种情况中块的配置为(64,4),与最好的情况(128,2)有相同数量的线程块,这两种情况应该显示出相同的并行性。这种情况相比(128,2)仍然表现较差,结论:线程块最内层维度的大小对性能起着的关键的作用。除了以上这些情况,其他配置的线程块的数量都比(128,2)少。因此,增大并行性(增加线程块数量)仍然是性能优化的一个重要因素。
线程块最少的那些示例应该显示出较低的可实现占用率,线程块最多的那些例子应该显示出较高的可实现占用率。这个理论可以用nvprof检测achieved_occupancy指标来验证一下:
第一种情况(64,2)在所有例子中可实现占用率最低,但它的线程块是最多的。这种情况在线程块的最大数量上遇到了硬件限制。
观察到(128,2)和(256,2)拥有最高的占有率,通过将block.y设置为1来增大块间并行性。
产生了最佳性能,(256,1)的块配置优于(128, 1) ,查看(256,1)的可实现占用率、加载吞吐量和加载效率。
最好的执行配置既不具有最高的可实现占用率,也不具有最高的加载吞吐量。从这些实验中可以推断出,没有一个单独的指标能直接优化性能。我们需要在几个相关的指标间寻找一个恰当的平衡来达到最佳的总体性能。
3.4 避免分支分化
通过重新组织数据的获取模式,可以减少或避免线程束分化。
这里的主要例子是并行归约算法。
并行加法的一个常用方法是使用迭代成对实现。首先需要根据线程块的数量把输入向量划分,每个线程块处理一个部分。然后一个线程处理的数据块只包含一对元素,并且一 个线程对这两个元素求和产生一个局部结果。然后,这些局部结果在最初的输入向量中就地保存。这些新值被作为下一次迭代求和的输入值。因为输入值的数量在每一次迭代后会减半,当输出向量的长度达到1时,最终的和就已经被计算出来了。
根据每次迭代后输出元素就地存储的位置,成对的并行求和实现可以被进一步分为以下两种类型:
·相邻配对:元素与它们直接相邻的元素配对
·交错配对:根据给定的跨度配对元素
在向量中执行满足交换律和结合律的运算,被称为归约问题。并行归约问题是这种运 算的并行执行。并行归约是一种最常见的并行模式,并且是许多并行算法中的一个关键运算。
接下来是使用全局内存进行的归约操作,我相信已经很好理解了,每个线程块处理一个区域,最后结果存到每个线程块的对应全局内存的第一个位置。
初步输出的结果非常简单,重点在于如何改善。
3.4.1 改善并行归约的分化
if ((tid % (2 * stride)) == 0) 只对偶数ID的线程为true,所以这会导致很高的线程束分化。在并行归 约的第一次迭代中,只有ID为偶数的线程执行这个条件语句的主体,但是线程束内的所有的线程都必须被调度
上下对比,利用的线程不同。部分和的存储位置并没有改 变,但是工作线程已经更新了。
修改后的代码如下。
如何理解:对于一个有512个线程的块来说,前8个线程束执行第一轮归约,剩下8个线程束什么 也不做。在第二轮里,前4个线程束执行归约,剩下12个线程束什么也不做。因此,这样 就彻底不存在分化了。在最后五轮中,当每一轮的线程总数小于线程束的大小时,分化就会出现。这时候就可以循环展开!
此外,交错实现比第一个实现快了1.69倍,比第二个实现快了1.34倍。这种性能的提升主要 是由reduceInterleaved函数里的全局内存加载/存储模式导致的。
我认为这一块指标很重要,就是用inst_per_warp指标来查看每个线程束上执行指令数量的平均值,因为之前分化导致每个线程都要执行,有些线程是被强迫执行的!但是解决分化之后,第一次就有一半线程束的线程没有执行操作,所以每个线程的平均指令数量肯定会减少!测试结果证明的确如此!
新的实现拥有更高的加载吞吐量:
这里其实不是很理解。。全局内存加载吞吐量是由什么影响的?
3.4.2 交错配对的归约
交错配对方法颠倒了元素的跨度。初始跨度是线程块大小的一 半,然后在每次迭代中减少一半。
测试结果如下。
交错实现比第一个实现快了1.69倍,比第二个实现快了1.34倍。这种性能的提升主要 是由reduceInterleaved函数里的全局内存加载/存储模式导致的,这里的原理应该是因为每个线程束的全局内存事务的数量减少了,相比于相邻配对来说,交错配对在全局内存load/store上相比更有优势。
3.5 展开循环
循环展开是一个尝试通过减少分支出现的频率和循环维护指令来优化循环的技术,这部分最好通过代码去理解哦。
在CUDA中,循环展开的意义非常重大。我们的目标仍然是相同的:通过减少指令消 耗和增加更多的独立调度指令来提高性能。因此,更多的并发操作被添加到流水线上,以产生更高的指令和内存带宽。这为线程束调度器提供更多符合条件的线程束,它们可以帮助隐藏指令或内存延迟。(这里是什么意思?)
3.5.1 展开的归约
每个线程块汇总了来自两个数据块的数据,再进行计算。只需要一半的线程块来处理相同的数据 集。请注意,这也意味着对于相同大小的数据集,向设备显示的线程束和线程块级别的并行性更低
网格配置需要改变。
即使只进行简单的更改,现在核函数的执行速度比原来快3.42倍。正如预想的一样,在一个线程中有更多的独立内存加载/存储操作会产生更好的性能,因为内存延迟可以更好地被隐藏起来。归约的展开测试用例和设备读吞吐量之间是成正比的。其中展开两次,四次和八次的结果如下,八次的吞吐量是最高的
3.5.2 展开线程的归约
这里比较难理解的部分就是volatile,最后32个线程循环展开的部分,这部分其实也是按顺序执行的,全部32个线程执行完第一句之后再执行第二次,每次处理的线程数减半。现在明白了,就是为了防止最后线程小于等于32个的时候的线程束内部的分化,因为线程束的执行是SIMT(单指令多线程)的,每条指令之后有隐式的线程束内同步过程。可以参考:【CUDA 基础】3.5 展开循环 | 谭升的博客
这里还解释了线程束内的线程同步所以不会产生竞争的问题。
第一步定义 volatile int类型变量我们先不说,我们先把最后这个展开捋顺一下,当只剩下最后下面三角部分,从64个数合并到一个数,首先将前32个数,按照步长为32,进行并行加法,前32个tid得到64个数字的两两和,存在前32个数字中
接着,到了我们的关键技巧了
然后这32个数加上步长为16的变量,理论上,这样能得到16个数,这16个数的和就是最后这个块的归约结果,但是根据上面tid<32的判断条件线程tid 16到31的线程还在运行,但是结果已经没意义了,这一步很重要(这一步可能产生疑惑的另一个原因是既然是同步执行,会不会比如线程17加上了线程33后写入17号的内存了,这时候1号才来加17号的结果,这样结果就不对了,因为我们的CUDA内核从内存中读数据到寄存器,然后进行加法都是同步进行的,也就是17号线程和1号线程同时读33号和17号的内存,这样17号即便在下一步修改,也不影响1号线程寄存器里面的值了),虽然32以内的tid的线程都在跑,但是没进行一步,后面一半的线程结果将没有用途了,
这样继续计算,得到最后的一个有效的结果就是 tid[0]。
上面这个过程有点复杂,但是我们自己好好想一想,从硬件取数据,到计算,每一步都分析一下,就能得到实际的结果。
volatile int类型变量是控制变量结果写回到内存,而不是存在共享内存,或者缓存中,因为下一步的计算马上要用到它,如果写入缓存,可能造成下一步的读取会读到错误的数据。
应用展开8次的归约和volatile,线程束停滞的百分比减少了,因为没有用这个展开之前,最后一波归约的32个线程束内部还是有分化的。
3.5.3 完全展开的归约
每个块的最大线程数都是1024,归约核函数中 循环迭代次数是基于一个线程块维度的,所以完全展开归约循环是可能的,也就是展开1024次,每个线程都展开。
因为最大维度是1024个线程,所以如果按最多个线程计算,第一次是512个线程归约,第二次是256个线程归约,第三次是128个线程归约,第四次是64个线程归约,这四次结束之后,就是32个线程的展开归约了,非常合理的完全展开。
3.5.4 模板函数的归约
唯一的区别是使用了模板参数替换了块大小。检查 块大小的if语句将在编译时被评估,如果这一条件为false,那么编译时它将会被删除,使 得内循环更有效率。例如,在线程块大小为256的情况下调用这个核函数,判断是否大于1024的语句将永远是false。
必须要在switch语句中调用。
最大的相对性能增益是通过reduceUnrolling8核函数获得的,在这个函数之中每 个线程在归约前处理8个数据块。有了8个独立的内存访问,可以更好地让内存带宽饱和及隐藏加载/存储延迟
3.6 动态并行
动态并行可以推迟到运行时决定需要在GPU上创建多少个块和网格,可以动态 地利用GPU硬件调度器和加载平衡器,并进行调整以适应数据驱动或工作负载。
在GPU端直接创建工作的能力可以减少在主机和设备之间传输执行控制和数据的需 求,因为在设备上执行的线程可以在运行时决定启动配置。
3.6.1 嵌套执行
子网格必须在父线程、父线程块或父网格完成之前完成。只有在所有的子网格都完成之后,父母才会完成。共享内存和局部内存分别对于线程块或线程来说是私有的,同时,在父母和孩子之间不是可见或一致的。全局内存是可见的。
以下是动态并行的一个例子:
假设输入线程是8个,nestedHelloWorld 核函数递归地调用三次,每次调用的线程数是上一次的一半。
观察这四个内核,其中父线程块在空白区域等待子线程块结束。
另外,如果调用两个线程块去执行,结果是每个子线程块的block索引都是0,这是因为虽然父网格包含两个线程块,但是每个线程块单独去递归执行的时候,子线程块都只有一个!(截图没有包含第一次执行)
3.6.2 嵌套归约
归并的嵌套并行例子:
有两种方式,第一种是每个线程块启动一个新的子网格,子网格维度为1,块维度为原先块的一半。这样启动的效率很低,因为有很多个子网格。
第二种方式是子网格只被第一个线程块的第一个线程启动,这样消耗很低,效率较高,但是每次需要传入块维度。(这里的代码比较难理解,但是我明白了!)
对应的具体代码如下,第一个例子是每个线程块都启动新的网格,这样效率很低。
当一个子网格被调用后,它看到的内存与父线程是完全一样的。因为每一个子线程只 需要父线程的数值来指导部分归约,所以在每个子网格启动前执行线程块内部的同步是没有必要的,去除同步线程后的代码。
第二种方式的代码一开始很难理解,但是只要明白每次只有一个新的网格被启动,并且每次启动的网格内的线程块数量不变,但是线程块大小减少一半(因为是归约,所需要的线程数量每次肯定是减少一半的)。
因为只有线程块0的线程0每次启动新的子网格,所以每次启动的时候,线程块的数量不会变
但是每个线程块内部的线程数量变少了,但是要确保每个线程块对正确的全局数据位置的访问。
__global__ void gpuRecursiveReduce2(int *g_idata, int *g_odata,
int iStride, int const iDim) {
// convert global data pointer to the local pointer of this block
int *idata = g_idata + blockIdx.x*iDim;
// stop condition
if (iStride == 1 && threadIdx.x == 0) {
g_odata[blockIdx.x] = idata[0]+idata[1];
return;
}
// in place reduction
idata[threadIdx.x] += idata[threadIdx.x + iStride];
// nested invocation to generate child grids
if(threadIdx.x == 0 && blockIdx.x == 0) {
gpuRecursiveReduce2 <<<gridDim.x,iStride/2>>>
(g_idata,g_odata,iStride/2,iDim);
}
}
对于这段代码,尤其要注意最后的函数调用:线程块数量没变,但是每个块的大小减半了,对应了之前用iDim来寻找正确的位置偏移。(也就是说,每次新的线程块的第一个线程,都能够正确寻找到全局内存中一样的对应位置,只不过每次的stride减少了一半)
检测时间,发现这种方法和之前的没有嵌套的版本接近!