参考资料
从AI系统角度回顾GPU架构变迁--从Fermi到Ampere(V1.2) - 知乎
https://docs.nvidia.com/cuda/cuda-c-programming-guide/
《算力芯片——高性能 CPU/GPU/NPU 微架构分析》
每一代GPU芯片的white paper,例如:
https://www.advancedclustering.com/wp-content/uploads/2022/03/gtc22-whitepaper-hopper.pdf
SM硬件架构基础
Volta GV100 Streaming Multiprocessor (SM)
GA100 Streaming Multiprocessor (SM)
上面展示了几个不同架构SM的区别,需要注意一些比较显著的异同点:
每个SM分成了4个子块,注意哪些部分是这4个子块共享的,哪些是这4个子块独立的。
比如shared mem和L1 cache是整个SM4个子块共享的,而register file, cuda core等是每个子块独立的。这些对CUDA编程实践和理解是有指导作用的。
注意每个子块的cuda core个数,比如GV100 GA100每个子块有16个INT32和FP32 cuda core,8个FP64 cuda core,4个SFU,而GA102没有FP64 cuda core。最新的hopper架构每个子块不是16个而是32个FP32 cuda core。
注意每个子块TensorCore的数量以及他们的具体参数规格。
CUDA SIMT编程模型基础
需要弄清的几个问题:
CUDA core的含义与线程的关系?
warp,线程块与SM的关系?
不同warp切换理解?
CPU编程写的程序一般是单线程串行执行的。在 SIMD(单指令多数据)中,一条指令同时适用于许多数据元素。而Nvidia GPU 采用SIMT(单指令多线程)模式进行并行计算。
我们首先需要写一个kernel函数,最后会创建出成千上万的线程,每个线程独立执行相同的kernel指令,但是处理不同的数据:
虽然每个线程执行相同的指令,但是所有的线程是按照block和grid两个层次进行管理的:
add<<<grid_size, block_size>>> (a, b, c);
每个线程块block包含几十数百个线程(一般应是32整数倍),而线程块内部的线程又是以32个线程组成为一个warp进行执行的。同时一个warp内部的32个线程是比较严格同步执行的(每个线程同一个时刻执行相同的指令)。最后多个block组成了一整个grid。
为什么这样组织是直接对应于硬件架构的:
对照前面的SM硬件架构,每个线程块所有线程是在同一个SM(包含4个子块执行的)执行的,而一个SM可以同时驻留和执行多个线程块。整个GPU一般有几十上百个SM可以执行,取决于具体硬件规格。
同时,每个warp 32个线程是在同一个SM子块内执行的,同一个线程块的多个warp可能分布在SM多个子块进行执行。
同一个子块内部的warp切换:GPU不同于CPU的一个特点是线程切换是极其迅速的。这是因为每个线程和线程块使用的资源是直接基于硬件资源保存的,而不是先把寄存器内存保存到内存,再从内存加载新线程的信息到寄存器然后再执行。这里会导致几注意点:1,SM会在当前warp处于某些等待时(比如当前warp内的线程在读取global mem,这需要数百个时钟),那这时会切换一个新的warp进行执行,从而可以显著提升硬件利用率和执行性能。2,因此虽然SM同时只能执行4个warp,但是应该有足够的warp驻留用于切换才能保证性能。每个线程块的线程数、每个SM能同时执行的线程块数量上限是可以通过CUDA提供接口进行查询的。但由于每个线程和线程块都是使用了实打实的寄存器和shared mem硬件资源,而硬件资源是有限的。因此每个线程和线程块的资源使用量决定了实际每个线程块包含的线程数和每个SM能同时执行的线程块数量。因此实际的程序要比较仔细规划每个线程块的线程数,每个线程和线程块使用的寄存器和shared mem资源,从而保证SM有足够的warp同时执行,一般应该有实际能够执行的4-8倍以上。
每个 warp 的执行上下文(程序计数器、寄存器等)在 warp 的整个生命周期内都在芯片上维护。寄存器文件、数据缓存和共享内存在线程块之间进行分区。因此,与其他上下文切换相比,在下一个时间步切换到另一个warp没有成本损失。但是可以驻留在 SM 中的预定义的最大线程块数和warp 数受到 GPU 容量的限制。This instruction can be selected from the same warp with no dependency on the last instruction, or more often an instruction of another warp. The execution time for many arithmetic instructions will take 2 clock cycles.
每个线程指令具体执行的逻辑:
CUDA程序SIMT这成千上万个线程执行跟CUDA core又是什么关系呢?
刚开始比较容易给人一种误解是好像是每个线程是在每个CUDA CORE执行的,实际并不是这样。
我们可以将kernel视为一系列指令。假设下一条指令是一个 INT32 操作。Nvidia GPU 将一个warp 32 个线程dispatch到 16 个 INT32 算术单元以同时执行指令(或分派到 16 个 FP32 单元以进行 FP32 操作)。
注意是把一个warp 32个线程的指令派遣到16个core,而不是32个,因为从前面SM图上可以看到每个SM子块只有16个FP32和INT32 cuda core,这也使得warp执行每条FP32/INT32指令实际需要2个时钟才能完成(hopper除外,因为它每个子块已经有32个CUDA CORE)。
同理,如果下一条指令是FP64,同一个warp 32个线程的指令需要dispatch到8个FP64 cuda core执行,因此需要更长的周期。
Fermi 中的 CUDA core同时提供 FP 和 INT 操作(时分复用),但 与 V100 和 Turing GPU 类似,Ampere 将它们分为独立的 INT32、FP32 和 FP64 单元。通过分离 FP32 和 INT32 内核,它允许并发执行 FP32 和 INT32 操作并增加指令发出吞吐量。许多应用程序的内循环都有执行指针算术(整数存储器地址计算)与浮点计算相结合的内循环,这些内循环受益于 FP32 和 INT32 指令的同时执行。流水线循环的每一次迭代都可以更新地址(INT32 指针算术),并为下一次迭代加载数据,同时在 FP32 中处理当前的迭代。
Here is another view of issuing instruction and execution in the Volta architecture in a processing block (sub-core).
CUDA程序的一些注意事项和优化点
基本原则是把GPU用满:一个SM能够同时执行多个线程块。同时一个grid应该有足够多的线程块。
一个SM能够同时异步执行多个线程块,一个SM子块异步并行执行多个warp
因为一个SM需要有足够多的warp才能够进行并发和切换warp保证性能。而每个SM能同时执行的warp数上限取决于这两者的最小值:
1.硬件限制和kernel参数设置(每个线程块和每个SM的线程和线程块数量是固定的可以通过接口查询的)。当资源充足大于线程和线程块使用的资源时,这时每个SM执行的warp数量受限于kernel设置的参数,比如每个线程块的线程数太少,那么由于SM同时执行的线程块数量有限,这就导致SM同时执行的线程数不够。一般一个线程块的线程数要达到128、256才能充分用满SM,这个参数可以进行调节从而找到一个最优值。
2.线程和线程块资源使用导致实际能够执行的数量限制。如果线程块的shared mem使用太多,比如一个线程块就用完了所有的shared mem的一半以上,这样一个SM最多只能执行1个线程块。为了保证一个SM能同时执行多个线程块,显然每个线程块只能用每个SM总的shared mem的几分之一。寄存器使用也是一样,寄存器使用合理时一个warp能够同时执行32个线程,同时一个子块的资源能满足同时驻留多个warp。而寄存器使用太多一个子块无法驻留多个warp,甚至极端情况一个warp的资源所有连32个线程都不够用。
一个grid应该有足够多的线程块
一个kenel是对应于一个grid,里面要有足够的线程块才能充分利用好整个GPU所有的SM。一方面一个SM本身就需要驻留多个线程块,那么整个GPU几十上百个SM用满的线程块数量应该要乘以一个比较大的倍数才够。
这里举一个深度学习中一个实际的reduce/layer_norm计算的例子,假如我们计算一个[200, 768] tensor最内部维度每一行的reduce mean,如果naive的想法每个线程计算一行那么总共的线程数才200。这样只能够生成一两个线程块,只能用上一两个SM,显然性能极差。而如果我们用一个warp来计算一行,那么就有200个warp,如果一个线程块4个warp则有50个线程块,能用上大部分SM。当然还可以使用一个线程块来计算一行,那么我们就有200个线程块。
当然这个reduce的例子存在一些其他的trade-off:因为reduce需要线程之间交换数据,使用warp计算一行时,前面提到过每个线程的寄存器是直接保存在硬件上,而同一个warp是在同一个SM子块运行的,这些子块共享寄存器文件,而不同子块共享数据只能通过shared mem。因此同一个warp之间不同线程交换数据可以通过warp shuffle (Warp-Level Primitives)直接交换寄存器数据,更加快速。而一个线程块计算一行需要先通过shared mem交换数据,性能相对较慢。如何平衡这个trade-off取决于任务量。
ampere架构的部分资源信息:
NVIDIA Ampere GPU Architecture Tuning Guide :: CUDA Toolkit Documentation
1.4.1.1. Occupancy
The maximum number of concurrent warps per SM remains the same as in Volta (i.e., 64), and other factors influencing warp occupancy are:
‣ The register file size is 64K 32-bit registers per SM.
‣ The maximum number of registers per thread is 255.
‣ The maximum number of thread blocks per SM is 32 for devices of compute capability 8.0 (i.e., A100 GPUs) and 16 for GPUs with compute capability 8.6 (GA102/104,如RTX3060等).
‣ For devices of compute capability 8.0 (i.e., A100 GPUs) shared memory capacity per SM is 164 KB, a 71% increase compared to V100's capacity of 96 KB. For GPUs with compute capability 8.6, shared memory capacity per SM is 100 KB.
‣ For devices of compute capability 8.0 (i.e., A100 GPUs) the maximum shared memory per thread block is 163 KB. For GPUs with compute capability 8.6 maximum shared memory per thread block is 99 KB.
CUDA储存层级和注意事项
寄存器
什么数据会被自动使用为寄存器?
shared memory的使用量通常是户明确定义可知的,而寄存器使用量如何确定?
每个线程有多少寄存器可用?如何避免寄存器使用过多?
不同于CPU,GPU对每个线程使用不同的硬件寄存器,切换线程时不会发生保存寄存器到内存和从内存加载寄存器内存的过程,因此线程切换十分高效。但SM总的寄存器资源和每个线程使用的寄存器数量决定了可以同时执行的线程数量。共享内存同样存在这个限制。
不同于CPU,GPU每个线程拥有不同的独立硬件寄存器,因此切换线程不需要保存和加载寄存器内存,线程切换代价低。
每个thread block的寄存器和共享内存使用量限制了每个SM能够同时执行的thread block数量,同时寄存器使用过多可能导致寄存器溢出导致数据存储到内存从而导致下降。
https://developer.download.nvidia.com/CUDA/training/register_spilling.pdf
CMakeLists.txt设置查看寄存器等使用情况:
set(CMAKE_CUDA_FLAGS "${CMAKE_CUDA_FLAGS} --ptxas-options=-v")
kernel里面的数组是使用了寄存器还是local mem:
CUDA编程中线程分配的数组在register中还是local memory中?_普通网友的博客-CSDN博客
Fast Dynamic Indexing of Private Arrays in CUDA | NVIDIA Technical Blog
也就是只有编译器可以静态确定array索引index时才会把数组放到register里面。
Warp shuffle
https://people.maths.ox.ac.uk/gilesm/cuda/lecs/lec4.pdf
由于同一个warp所有线程是在同一个SM块内运行的,这些线程是使用了同一块寄存器空间,这使得同一个warp内的线程可以通过寄存器来高效的交换数据。warp shuffle指令正是提供了这样的功能。而同一个线程块不同warp之间要高效交换数据只能通过shared mem。
基于warp suffle实现把同一个warp内部所有数据累加到第一个线程的代码:
template <typename T>
__inline__ __device__ T WarpReduceSum(T data) {
#pragma unroll 5 // for warp_size = 32
for (int offset = 16; offset > 0; offset >>= 1) {
data += __shfl_down_sync(0xFFFFFFFF, data, offset);
}
// optional broadcast value of the first thread to all threads in warp
data = __shfl_sync(0xFFFFFFFF, data, 0);
return data;
}
共享内存
共享内存用于一个thread block不同warp之间高效传递数据,例如基于thread block进行reduce,需要把一个线程块所有的线程的数据汇总在一起。shared memory一个注意点是需要避免bank conflict。另外最新的NV gpu增加了一个线程块集群和分布式共享内存的概念,在grid和thread block中间引入了一个新的层次结构:线程块集群,线程块集群可以基于分布式共享内存高效的传递数据而不需要基于global memory。
bank conflict原理和如何避免?
https://developer.nvidia.com/blog/using-shared-memory-cuda-cc/
bank conflict时不会切换warp降低延迟,因此对性能影响比较大。
常用的避免bank conflict方法是padding,即对原始矩阵的行长度进行加长,使得实际矩阵为shared mem保存矩阵的子矩阵。如下图展示了宽度为32的矩阵通过+1 padding可以避免不同线程访问同一列的bank冲突,d_id和b_id分别是数据和bank的id。实际上也可以+其他padding,比如+4或者+8还可以满足每一行数据的128/256字节对齐要求。图中的d_id是数据的id,而b_id是bank的id。
如何实现double buffer/prefetch?
double buffer是使用两个buffer,实现读取/写回与计算的pipe计算,保持计算单元一直处于忙碌状态。double buffer实现需要异步执行从而实现计算和数据拷贝的overlap。
从global mem加载到寄存器本身是异步的(不会阻塞后续指令除非后面用到了这个寄存器)。而ampere之前架构从global mem直接加载到shared需要经过寄存器中转,由于写shared mem依赖寄存器ready,导致需要等待global mem完成。ampere之前的架构为了实现global 到shared的异步,可以手动基于寄存器中转。也就是先手动把global读取到reg,然后执行其他无关计算指令,然后再把reg内存拷贝到shared,从而隐藏global mem读取等待。
apmere引入了新的不需要寄存器中转的异步拷贝LDGSTS指令从global mem读取到shared mem,减少了寄存器的压力和不必要的数据中转,进一步节省了功耗。并且因为这条指令的异步性,可以作为背景操作和前台的计算指令overlap执行,进一步提升整体计算效率。
double buffer的一个简单的演示代码:
prefetch data block0
for loop:
prefetch next block
compute cur block
CUDA 11.0 introduces an async-copy feature that can be used within device code to explicitly
manage the asynchronous copying of data from global memory to shared memory. This
feature enables CUDA kernels to overlap copying data from global to shared memory with
computation. It also avoids an intermediary register file access traditionally present between
the global memory read and the shared memory write.
https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#async_data_operations
Hopper进一步增加了TMA硬件模块。
全局内存访问
重要参考:
内存带宽计算
Coalesced memory accesses合并内存访问
For devices of compute capability 6.0 or higher, the requirements can be summarized quite easily: the concurrent accesses of the threads of a warp will coalesce into a number of transactions equal to the number of 32-byte transactions necessary to service all of the threads of the warp.
也就是一个warp的数据访问会被整理成多个32字节对齐的32字节访问。
例如一个warp 32个线程从n*32地址开始的内存每个线程读取一个float(4字节),那么总共读取32*4个字节,那么会被整理为4次coalesced内存访问。而如果起始地址不是32整数倍,那么访问的数据跨越了5个32字节数据块,那么就需要5次访问,相比4次访问内存带宽显著下降。
Vectorized load/store
CUDA Pro Tip: Increase Performance with Vectorized Memory Access | NVIDIA Technical Blog
相比于标量的数据类型,如float, int, half等,向量化数据类型可以明显提升内存访问带宽,如float2, float4, half2, half4等等。
向量化数据访问和计算有几个好处:1是内存访问时,只需要计算一次地址就可以访问得到多个数据,地址计算相应的指令占比明显降低。2是在循环任务中,向量化访问和计算可以成倍降低循环次数。例如一个warp对768长度的元素做reduce,那么scale需要循环24次,但是采用float4/half4向量访问和计算,那么只需要循环6次,那么内存访问和计算的指令占比明显提升,而循环本身的开销占比被明显降低。
Cache
L1 cache
L1 cache是被同一个线程块共享的,L1 cache的cache line大小为128字节。
L2 cache
L2 cache是整个kernel grid共享的。
常量内存,纹理内存
一般特定应用场景才使用,AI里面是否有应用的空间?
线程协作
CUDA Warp-Level Primitives
Using CUDA Warp-Level Primitives | NVIDIA Technical Blog
Register Cache: Caching for Warp-Centric CUDA Programs | NVIDIA Technical Blog
Cooperative Groups: Flexible CUDA Thread Programming | NVIDIA Technical Blog
线程块以warp为单位由SM自动调度执行的,这一过程对程序员基本上不感知的,但也可以显示地在warp层面进行操作。例如Warp-level intra register exchange,因为同一个warp的线程执行和寄存器内容在同一个sm块,因此同一个warp线程存在便利的手段相互交换寄存器数据的可能(register-shuffle),而不同warp可能在不同sm块执行,只能通过shared memory交换数据。
CUDA 9 introduced three categories of new or updated warp-level primitives.
- Synchronized data exchange: exchange data between threads in warp.
__all_sync
,__any_sync
,__uni_sync
,__ballot_sync
__shfl_sync
,__shfl_up_sync
,__shfl_down_sync
,__shfl_xor_sync
__match_any_sync
,__match_all_sync
- Active mask query: returns a 32-bit mask indicating which threads in a warp are active with the current executing thread.
__activemask
- Thread synchronization: synchronize threads in a warp and provide a memory fence.
__syncwarp
Please see the CUDA Programming Guide for detailed descriptions of these primitives.
这里展示基于warp shuffle使用每个warp来计算二维tensor每一行平均的例子:
__global__ void reduce_mean_row_warp(const float* __restrict__ A,
float* __restrict__ B,
int row, int col) {
int tid = blockDim.x * blockIdx.x + threadIdx.x;
int cur_row = tid / warpSize;
int start_col = tid % warpSize;
if (cur_row < row) {
float ratio = 1.0f / col;
int addr_offset = cur_row * col;
float mean_val = 0;
for (int i = start_col; i < col; i += warpSize) {
mean_val += ratio * A[addr_offset + i]; // method 1
}
// use warp shuffle to get correct mean for thread 0 from all threads in a warp
mean_val += __shfl_down_sync(0xFFFFFFFF, mean_val, 16);
mean_val += __shfl_down_sync(0xFFFFFFFF, mean_val, 8);
mean_val += __shfl_down_sync(0xFFFFFFFF, mean_val, 4);
mean_val += __shfl_down_sync(0xFFFFFFFF, mean_val, 2);
mean_val += __shfl_down_sync(0xFFFFFFFF, mean_val, 1);
if (start_col == 0) {
B[cur_row] = mean_val;
}
}
}
TensorCore
warp内线程如何协同使用TensorCore?
to do
其他常见注意事项
分支导致的warp divergence应该尽量避免,比如让同一个warp尽量处理同一个分支。
__restrict__
关键字可能带来一些优化效果,它具有与C99 restrict
关键字基本相同的语义。
性能优化最重要的是知道瓶颈在哪里
1,整个模型的瓶颈在什么地方?是内存分配,数据拷贝?还是某些算子耗时?
2,单个算子里面,瓶颈又在哪里?数据计算?数据读写?偏置计算?
CUDA的一些新特性
异步内存分配
内存分配和重用是推理引擎极其重要的一块,因为每次重新内存分配和释放是很耗时的过程,通常需要实现一个内存池,提前分配好内存,然后基于内存池来进行内存重用,提高性能。而
而cuda11.2推出新功能底层自动实现了这样的功能,无需用户再自己实现复杂的内存重用算法。
cudaMallocAsync(&ptr, size, stream); // Allocates physical memory
kernel<<<...,stream>>>(ptr);
cudaFreeAsync(ptr, stream); // releases memory back into a pool
cudaMallocAsync(&ptr, size, stream); // Reuses previously freed pointer
kernel<<<...,stream>>>(ptr);
cudaFreeAsync(ptr, stream); // releases memory back into a pool
.... // Executes other work in the stream
profiling工具
nvprof
nvprof python xx.py
nvprof xx_bin
NVIDIA Nsight Systems
NVIDIA Nsight Systems | NVIDIA Developer
linux端下载Linux Host .run Installer,windows端下载Windows Host分别安装
nsys profile xx执行生成qdrep文件使用windows host打开可以看到可视化的time line图
windows端nsys使用,先把nsys路径添加到系统环境变量:C:\Program Files\NVIDIA Corporation\Nsight Systems 2022.3.4\target-windows-x64
在anaconda的prompt命令行执行:
nsys profile D:\ProgramData\Anaconda3\python.exe matmul_tf.py
直接python xx.py可能提升python找不到,可以用上面全路径。
ref
从AI系统角度回顾GPU架构变迁--从Fermi到Ampere(V1.2) - 知乎
《CUDA并行程序设计 GPU编程指南》
《 PROFESSIONALCUDA C Programming》
Diving Deep Into The Nvidia Ampere GPU Architecture
Dissecting the NVIDIA Volta GPU Architecture via Microbenchmarking
NVIDIA A100 Tensor Core GPU Architecture
Drilling Down Into Nvidia’s “Pascal” GPU
https://jonathan-hui.medium.com/ai-chips-a100-gpu-with-nvidia-ampere-architecture-3034ed685e6e
https://developer.nvidia.com/blog/nvidia-ampere-architecture-in-depth/
CUDA微架构与指令集(4)-指令发射与warp调度 - 知乎
Programming Guide :: CUDA Toolkit Documentation
如何评价英伟达 3 月 22 日发布的全新 GPU H100 ? - 知乎
NVIDIA Hopper Architecture In-Depth | NVIDIA Technical Blog
声明:
本文部分内容使用了文中所引用文献和网页的内容。