Programming Massively Parallel Processors部分章节重点摘录
Chap2 Heterogeneous data parallel computing
1、CUDA 代码的结构:The structure of a CUDA C program reflects the coexistence of a host (CPU) and one or more devices (GPUs) in the computer. Each CUDA C source file can have a mixture of host code and device code. By default, any traditional C program is a CUDA program that contains only host code. One can add device code into any source file.
2、CUDA runtime 提供的内存分配和回收接口
- cudaMalloc:内存申请函数,用于在设备上开辟一段空间(device global memory)。该函数与cudaMallocManaged 的区别在于后者分配的空间会使用 unified memory 进行自动调度。需要注意该函数的输入的指针类型是二级指针(void**),这样该接口就不会受限于特定数据类型类型的指针
- cudaFree:使用 cudaMalloc 开辟完空间后需要使用函数对空间进行释放
float* A_d int size=n * sizeof(float); cudaMalloc((void**)&A_d, size); // A_d为指向device global memory的地址 ... cudaFree(A_d); // 释放分配给A_d的device global memory并放回至available pool
- cudaMemcpy:用于在主机和设备之间同步数据。第一个入参是目的地址,第二个入参为源地址
3、CUDA Kernel 的运行结构
- SPMD 分布式设计模式:single-program multiple-date,指的是多个计算节点执行相同的程序,但是每个节点处理的数据不同。SPMD 模型通常用于并行计算,可以将大规模的数据集分成多个小块,由不同的计算节点进行并行处理
- blockDim:属于 built-in variable,用来表示一个 block 的维度(可以是一维、二维或者三维,分别对应 blockDim.x,blockDim.y 和 blockDim.z)。考虑到 hardware efficiency,最好是 32 的倍数。CUDA 3.0 以上版本中,一个 block 中最多可以有 1024 个线程
- 举例:在一个 block 中进行 vector add 计算。需要注意两点
- 调用下面这个 vecAddKernel 后在设备侧会 launch 一个新的 grid of threads(因为使用了__global__,说明该函数由 CPU 调用,并行计算任务会被发射到 GPU 的任务调用单元;而__device__则不会引起任何新的 device threads 的 launching)
- 下面代码中没有 loop 操作,因为一个 grid 中每个线程的运算相当于原 loop 中的一次迭代,这种模式被称为 loop parallelism,即 original sequential code are executed by threads in parallel
__global__ void vecAddKernel(float* A, float* B, float* C, int n) {
int i = threadIdx.x + blockDim.x * blockIdx.x;
if (i < n) {
C[i] = A[i] + B[i];
}
}
4、CUDA Kernel 的调用(略)5、编译:可以使用 nvcc,该编译器会把 host code 和 device code 分开,host code 通过标准 C/C++ compiler 编译,device code 通过 nvcc 编译转变为 PTX 文件(一种二进制文件)。这类 PTX 文件会被 nvcc runtime component 进一步被编译为 real object files 最终在 GPU 设备上运行
Chap3 Multidimensional grids and data
- 整型向量类型 dim3:该三维向量中三维参数分别为 x,y,z,不使用的维度可以设置为 1。维度参数可以常量,也可以是变量,使用变量的好处是 grid 可以在不同情况下都拥有足够多的线程来 cover 向量的值
dim dimGrid1(32, 1, 1) // 维度也可以是变量 dim dimGrid2(ceil(n/256.0), 1, 1) dim dimBlock(128, 1, 1) vecAddKernelz<<<dimGrid1, dimBlock>>>(...)
- 单个 block 中的线程数上限一般是 1024,因此 block 三个维度上的总线程数加起来不得超过 1024
- grid 中的 block 在表示时,其 label 一般使用 (blockIdx.y, blockIdx.x) 这种格式,block 中线程的表示也是类似。即 highest dimension comes first
- 编译要求:ANSI C 标准要求编译时二维向量的列的维度必须确定,但对于 dynamically allocated arrays 这种情况列的维度是无法提前确定的。因此开发者需要手动将二维向量线性化(linearize),即将 dynamically allocated 2D array 转换为 equivalent 1D array 后编译器才能处理
- 内存结构和访问效率:目前的内存地址也是一维线性的,cache 的设计也是根据一维地址设计的,访存指令也是根据一维地址设计的,因此降到一维后的数据内存连续,可以提高访存效率,缓存命中率高(多维向量需要多次 malloc 和释放,容易形成内存碎片)
- 便于使用向量化指令:CPU 使用的 AVX 指令集以及 Tensor Core 使用的 MMA(matrix multiply-accumulate)对内存布局都有严格要求,不连续的内存很难使用这些优化指令。并且 CUDA 编程的核心在于优化内存访问,内存不连续时访问效率很低
- Row-major layout:每一行中的所有元素放置在连续内存上,一行接着一行地放。C 编译器一般都使用这种线性化方式
- Column-major layout:每一列中的所有元素放置在连续内存上。FORTRAN 编译器一般使用这种线性化方式
Chap4 Compute architecture and scheduling
GPU 架构
Block scheduling
线程同步函数:__syncthreads()
2、注意事项:一个 block 中的所有线程必须执行同一个__syncthreads() 函数。因为每次该函数被调用都表示不同的 barrier synchronization points,如果在 if-else 语句中不同条件都调用了该函数则会出现死锁(deadlock)等问题
- 应该在相近的时间内执行完毕以免产生过长的线程间彼此等待的时间
- 每个线程必须能够获取足够的资源并最终都能到达 barrier,不然会造成死锁。这一点 CUDA runtime 会对其进行
4、Transparent Scalability:由于不允许不同 block 间的线程同步,所以一个 grid 中的所有 blocks 可以以任意顺序被执行。这种灵活性有利于 scalable implementation,对于计算资源较少的 low-cost system,同一时刻可以选择只执行小部分 blocks,对于计算资源较多的 higher-end implementation 则可以选择执行较多的 blocks(如今 GPU 可以同时执行上百个 blocks)。这种模式下,同样的应用程序代码可在不同硬件设备上(可用资源各不相同)执行,而不需要开发者根据环境不同而修改代码,该能力被称为 Transparent Scalability
线程束与 SIMD 模式
- SIMD 模式下指令的读取和分发:SM 上每 N 个处理器核(cores,也就是 SP)会形成一个 processing block,该 block 会共享 1 个 instruction fetch/dispatch unit(例如 NVIDIA Ampere A100 架构中有 64 个 cores 被分成了 4 个 processing blocks,每个 block 有 16 个 cores)。同个 warp 内的所有线程会被分配到同个 processing block 内,这些线程会获取到同一条单指令并同时执行该指令,这也是为何 warp 的 execution behavior 会被称为 SIMD
- SIMD 模式的优点:降低了硬件控制成本(hardware manufacturing cost and power consumption)。因为 instruction fetch/dispatch unit 被多个执行单元所共享,所以硬件的任务主要就集中在提升运算吞吐量上而非指令控制上
- SIMD 模式的缺点:类似于底层原语,里面的操作是不允许存在分支的,所有的操作都是作为一条指令存在。因此一个 warp 内的线程不能有不同的 control flow(例如 if-else)
![]() |
SIMD模式下指令和数据处理(PU=Process Unit) |
- 减少了指令预取带来的等待时间
- 内存不需要是连续的(SIMD 因为只是对指令的简单封装,仍然要求内存连续)
- 允许进行逻辑判断(SIMD 不允许有分支)
Control divergence
- 表面原因:handling boundary conditions when mapping threads to data。一般情况下线程数是 block 数量的倍数,但是需要运算的数据量则不一定是 block 数量的整数倍,所以个别 block 中的某些 warp(往往是 last warp)中就可能会出现线程去做其他运算的情况,从而发生 control divergence。不过随着处理数据量的增加,control divergence 带来的 performance impact 随之减少,因为发生 control divergence 的 warp 占所有 warps 的比重会越来越少
- 底层原因:Volta 架构后的 GPU 上,不同分支可以进行并行执行,这种运行模式被称为独立线程调度机制(independent thread scheduling),原因在于 Volta 架构后的 GPU 中,每一个线程可以有自己独立的程序计数器 PC 以及堆栈,这使得每个线程可以独立进行线程的调度(而之前的 GPU 中一个 warp 内的不同线程使用共同的程序计数器)
- 应对措施:开发者不应该想当然认为一个 warp 内所有线程的执行时间均相同,所以如果想要对这些线程进行同步的话需要调用 warp-level primitives 提供的 __syncwarp 函数来确保 warp 内线程的同步。CUDA9 之后引入了三类 warp-level primitives,分别是
- Synchronized data exchange:用于在 warp 中的线程之间交换数据
- Active mask query:返回一个 32 位掩码,指示 warp 中的哪些线程在当前执行线程中处于活动状态
- Thread synchronization:同步 warp 中的线程,并提供内存隔离(memory fence)
Latency tolerance
资源分配
- block slots 和 thread slots 的错配:如果一个 block 包含 32 个线程,那么 2048 个 thread slots 就需要 64 个 blocks,但是像 Volta 架构的 GPU 最大只支持 32 个 blocks,也就是说单次只能加载 1024 个线程,此时 occupancy 计算出来就只有 50%
- 每个 block 中的线程数无法被 block 所能容纳的最大线程数整除:比如 block size 为 768,那么每个 SM 上只能容纳 2 个 block,此时该 SM 上还有 512 thread slots 未被初始化
- register 和 shared memory:每个 kernel 中的线程对 register 的需求并不一致,例如A100 GPU 允许每个 SM 最多使用 65536 个 registers,所以线程对 register 的需求会影响 occupancy(参见 Chap 5)
资源申请
- cudaGetDeviceCount:该 API 接口可以获取当前设备上支持 CUDA 的设备数
- cudaGetDeviceProperties:该 API 接口可以获取特定 CUDA 设备的属性
- cudaDeviceProp:CUDA 内置类,包含了许多设备信息。具体源码如下
/**
* CUDA device properties
*/
struct __device_builtin__ cudaDeviceProp
{
char name[256]; /**< 设备名称,比如1080Ti ASCII string identifying device */
cudaUUID_t uuid; /**< 16-byte unique identifier */
char luid[8]; /**< 8-byte locally unique identifier. Value is undefined on TCC and non-Windows platforms */
unsigned int luidDeviceNodeMask; /**< LUID device node mask. Value is undefined on TCC and non-Windows platforms */
size_t totalGlobalMem; /**< 设备上全局内存的总量,单位是字节 Global memory available on device in bytes */
size_t sharedMemPerBlock; /**< 在一个线程块Block中可使用的最大共享内存数量* Shared memory available per block in bytes /
int regsPerBlock; /**< 每个线程块中可用的32位寄存器数量 32-bit registers available per block */
int warpSize; /**< 在一个线程束Warp中包含的线程数量 Warp size in threads */
size_t memPitch; /**< 在内存复制中最大的修正量Pitch,单位为字节 Maximum pitch in bytes allowed by memory copies */
int maxThreadsPerBlock; /**< 在一个线程块中可以包含的最大线程数量 Maximum number of threads per block */
int maxThreadsDim[3]; /**< 在多维线程数组中,每一维可以包含的最大线程数量 Maximum size of each dimension of a block */
int maxGridSize[3]; /**< 在一个线程格Grid,每一维可以包含的最大线程数量 Maximum size of each dimension of a grid */
int clockRate; /**< Clock frequency in kilohertz */
size_t totalConstMem; /**< 常亮内存总量 Constant memory available on device in bytes */
int major; /**< 设备计算功能集的主板号 Major compute capability */
int minor; /**< 设备计算功能集的此版本号 Minor compute capability */
size_t textureAlignment; /**< 设备的纹理对齐需求Alignment requirement for textures */
size_t texturePitchAlignment; /**< Pitch alignment requirement for texture references bound to pitched memory */
int deviceOverlap; /**< bool类型,表示设备是否可以同时执行一个cudamemery调用和一个核函数调用 Device can concurrently copy memory and execute a kernel. Deprecated. Use instead asyncEngineCount. */
int multiProcessorCount; /**< 设备上多处理器的数量 Number of multiprocessors on device */
int kernelExecTimeoutEnabled; /**< bool类型,表示该设备上的核函数是否存在运行时间限制 Specified whether there is a run time limit on kernels */
int integrated; /**< bool, 设备是否是一个集成GPU Device is integrated as opposed to discrete */
int canMapHostMemory; /**< bool,表示设备是否将主机内存映射到CUDA设备地址空间 Device can map host memory with cudaHostAlloc/cudaHostGetDevicePointer */
int computeMode; /**< 设备的计算模式,默认(Default),独占(Exclusize),禁止(Prohibited)Compute mode (See ::cudaComputeMode) */
int maxTexture1D; /**< 一维纹理的最大大小 Maximum 1D texture size */
int maxTexture1DMipmap; /**< Maximum 1D mipmapped texture size */
int maxTexture1DLinear; /**< Maximum size for 1D textures bound to linear memory */
int maxTexture2D[2]; /**< 二维纹理的最大维数Maximum 2D texture dimensions */
int maxTexture2DMipmap[2]; /**< Maximum 2D mipmapped texture dimensions */
int maxTexture2DLinear[3]; /**< Maximum dimensions (width, height, pitch) for 2D textures bound to pitched memory */
int maxTexture2DGather[2]; /**< Maximum 2D texture dimensions if texture gather operations have to be performed */
int maxTexture3D[3]; /**< 三维纹理的最大维数 Maximum 3D texture dimensions */
int maxTexture3DAlt[3]; /**< Maximum alternate 3D texture dimensions */
int maxTextureCubemap; /**< Maximum Cubemap texture dimensions */
int maxTexture1DLayered[2]; /**< Maximum 1D layered texture dimensions */
int maxTexture2DLayered[3]; /**< Maximum 2D layered texture dimensions */
int maxTextureCubemapLayered[2];/**< Maximum Cubemap layered texture dimensions */
int maxSurface1D; /**< Maximum 1D surface size */
int maxSurface2D[2]; /**< Maximum 2D surface dimensions */
int maxSurface3D[3]; /**< Maximum 3D surface dimensions */
int maxSurface1DLayered[2]; /**< Maximum 1D layered surface dimensions */
int maxSurface2DLayered[3]; /**< Maximum 2D layered surface dimensions */
int maxSurfaceCubemap; /**< Maximum Cubemap surface dimensions */
int maxSurfaceCubemapLayered[2];/**< Maximum Cubemap layered surface dimensions */
size_t surfaceAlignment; /**< Alignment requirements for surfaces */
int concurrentKernels; /**< bool,表示设备是否支持在同一个上下文中同时执行多个核函数 Device can possibly execute multiple kernels concurrently */
int ECCEnabled; /**< Device has ECC support enabled */
int pciBusID; /**< PCI bus ID of the device */
int pciDeviceID; /**< PCI device ID of the device */
int pciDomainID; /**< PCI domain ID of the device */
int tccDriver; /**< 1 if device is a Tesla device using TCC driver, 0 otherwise */
int asyncEngineCount; /**< Number of asynchronous engines */
int unifiedAddressing; /**< Device shares a unified address space with the host */
int memoryClockRate; /**< Peak memory clock frequency in kilohertz */
int memoryBusWidth; /**< Global memory bus width in bits */
int l2CacheSize; /**< Size of L2 cache in bytes */
int maxThreadsPerMultiProcessor;/**< Maximum resident threads per multiprocessor */
int streamPrioritiesSupported; /**< Device supports stream priorities */
int globalL1CacheSupported; /**< Device supports caching globals in L1 */
int localL1CacheSupported; /**< Device supports caching locals in L1 */
size_t sharedMemPerMultiprocessor; /**< Shared memory available per multiprocessor in bytes */
int regsPerMultiprocessor; /**< 32-bit registers available per multiprocessor */
int managedMemory; /**< Device supports allocating managed memory on this system */
int isMultiGpuBoard; /**< Device is on a multi-GPU board */
int multiGpuBoardGroupID; /**< Unique identifier for a group of devices on the same multi-GPU board */
int hostNativeAtomicSupported; /**< Link between the device and the host supports native atomic operations */
int singleToDoublePrecisionPerfRatio; /**< Ratio of single precision performance (in floating-point operations per second) to double precision performance */
int pageableMemoryAccess; /**< Device supports coherently accessing pageable memory without calling cudaHostRegister on it */
int concurrentManagedAccess; /**< Device can coherently access managed memory concurrently with the CPU */
int computePreemptionSupported; /**< Device supports Compute Preemption */
int canUseHostPointerForRegisteredMem; /**< Device can access host registered memory at the same virtual address as the CPU */
int cooperativeLaunch; /**< Device supports launching cooperative kernels via ::cudaLaunchCooperativeKernel */
int cooperativeMultiDeviceLaunch; /**< Device can participate in cooperative kernels launched via ::cudaLaunchCooperativeKernelMultiDevice */
size_t sharedMemPerBlockOptin; /**< Per device maximum shared memory per block usable by special opt in */
int pageableMemoryAccessUsesHostPageTables; /**< Device accesses pageable memory via the host's page tables */
int directManagedMemAccessFromHost; /**< Host can directly access managed memory on the device without migration. */
};
Chap5 Memory architecture and data locality
访存效率
CUDA 内存类型
- Global memory & constant memory:off-chip,线程共享,两者均可从 host 侧进行读写操作,前者还可以从 device 侧进行读写操作,但是后者只支持 device 侧的读操作。设立 constant memory 的主要作用是为了解决一个 warp 内多线程在访问相同数据时速度太慢的问题(其通过 cache 产生多个数据副本避免了 thread 访问冲突,从而提高并行度)
- Local memory:off-chip,线程独享。位于 global memory 内,主要针对 register 不足时数据的存放(包括 statically allocated array,spilled register 以及线程调用栈中的数据),访存时延与 global memory 类似都比较慢
- register & shared memory:两者均是 on-chip 类型的内存,register 线程独享,用于存放经常要访问的数据;shared memory 则被一个 block 内的所有线程共享,用于存放经常需要被线程共享的数据
- 指令数量更少:each access to registers involves fewer instructions than an access to the global memory
- 获取 global memory 需要额外的开销:if an operand value is in the global memory, the processor needs to perform a memory load operation to make the operand value available to the ALU
- 能源消耗更少:the energy that is consumed for accessing a value from the register file is at least an order of magnitude lower than for accessing a value from the global memory
Tiling for Reduced Memory Traffic
__global__ void MatrixMulKernel(
float *d_M, float* d_N, float* d_P, int Width){
__shared__ float Mds[TILE_WIDTH][TILE_WIDTH];
__shared__ float Nds[TILE_WIDTH][TILE_WIDTH];
int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
// Identify the row and column of the d_p element to work on
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0;
for(int ph = 0; ph < Width / TILE_WIDTH; ph++) {
Mds[ty][tx] = d_M[Row * WIDTH + ph * TILE_WIDTH + tx];
Nds[ty][tx] = d_N[(ty + ph * TILE_WIDTH) * WIDTH + Col];
__syncthreads();
for(int k = 0; k < TILE_WIDTH; k++)
{
Pvalue += Mds[ty][k] * Nds[k][tx];
}
__syncthreads();
}
d_P[Row * Width + Col] = Pvalue;
}
- tx,ty,bx,by 都是 automatic scalar variables,所以 runtime system 会为每个线程都创建对应的这些变量,并且将它们保存在 registers 中,生存期与对应的线程一致
- 每个线程负责计算 P 矩阵中的一个元素
- 外层循环代表 phase 的遍历,变量 ph 表示当前已经做过 dot product 的 phase 个数;内层循环代表矩阵 P 中每个元素的计算(累加)
- 第一个 syncthreads 的作用:read-after-write dependence,保证 block 中的所有线程将对应的元素加载到了 shared memory 之后(写操作)再进行点积运算(读操作)
- 第二个 syncthreads 的作用:write-after-read dependence,保证在加载下一个 block 之前(再次写操作) shared memory 中的数据已完成点积运算(读操作)
- 外层循环加上内层循环这一过程被称为 strip mining
- CPU 中使用 CPU cache 来保证 reused data 隐式处于 on-chip 的状态,从而在特定时间窗口中 CPU 线程可以再 cache 中找到 reused data
- GPU 则将这类数据显式地保存到了 shared memory 上
- 两者 tiling 做法不同的原因:CPU Core 同一时刻只运行 1-2 个线程,所以只需要用 cache 来保存数据即可,而 GPU SM 同一时刻运行大量的线程来隐藏访存时延,这些线程会竞争 cache slots 从而带来风险,因此需要使用 shared memory 来保存会被重复使用的数据
Boundary checks
for(int ph = 0; ph < ceil(Width / (float)TILE_WIDTH; ++ph)
{
if((ROW < Width) && (ph * TILE_WIDTH + tx) < Width)
Mds[ty][tx] = M[Row * Width + ph * TILE_WIDTH + tx];
else Mds[ty][tx] = 0.0f;
if((ph * TILE_WIDTH + ty) < Width && Col < Width)
Nds[ty][tx] = N[(ph*TILE_WIDTH + ty)*Width + Col];
else Mds[ty][tx] = 0.0f;
__syncthreads();
for(int k = 0; k < TILE_WIDTH; ++k) {
Pvalue += Mds[ty][k] * Nds[k][tx];
}
__syncthreads();
}
if((Row < Width) && (Col < Width)
P[Row * Width + Col] = Pvalue;
// 原来是if((ROW < Width) && (ph * TILE_WIDTH + tx) < Width)
M: Col < J && (ph * TILE_WIDTH + tx) < K
// 原来是((ph * TILE_WIDTH + ty) < Width && Col < Width)
N: (ph * TILE_WIDTH + ty) < K && Col < L
Memory usage 对 occupancy 的影响
#define TILE_WIDTH 16
__global__ void MatrixMulKernel(
float *d_M, float* d_N, float* d_P, int Width,
unsigned Mdz_sz, unsigned Ndz_sz) {
extern __shared__ char float Mds_Nds[];
float *Mds = (float *)Mds_Nds
float *Nds = (float *)Mds_Nds + Mds_sz
}
// 对MatrixMulKernel的调用如下
size_t = calculate_appropriate_SM_usage(devProp.sharedMemPerBlock,...)
MatrixMulKernel<<<dimGrid, dimBlock, size>>>(Md,Nd,Pd,Width,size/2,size/2)
Chap6 Performance considerations
Memory coalescing
2、DRAM 中数据获取原理:global memory 位于 DRAM cell 上,从 DRAM cell 中读取数据时,晶体管会通过微量电荷的充放电来驱动传感器的电容线进入高位(highly capacitive line),此后传感器便开始检查晶体管中的电荷数量应该被识别为数字“1”还是“0”。该过程要耗费十几纳秒,与数据获取速度(大约是 subnanosecond per byte)相比这一速度是非常慢的,因此现代 DRAM 的设计中使用了并行方法来增加数据读取速率(即 memory access throughput)
- 使用 row-major layout 来存储矩阵时,每次迭代不同线程读取的是每一行对应的所有数据,由于这些数据保存在连续地址上,所以属于 coalesced memory accesses;
- 使用 column-major layout 来存储矩阵时,由于矩阵 M 是按照列优先保持,所以每次迭代不同线程读取的数据并不连续(相邻线程读取的数据所在地址相差一个 Width,在实际矩阵乘运算中 Width 的值可能是几百甚至高达几千),所以矩阵 M 中数据的获取无法在硬件中获得 coalescing
- 无法读取连续内存数据的优化方式:重新调整线程与数据之间的映射关系;重新调整数据存储结构;在 global memory 和 shared memory 之间以支持 coalescing 的方式传递数据,而当 shared memory 上进行不支持 coalescing access 的运算(例如 corner turning)
![]() |
row-major layout |
![]() |
row-major layout代码 |
![]() |
column-major layout |
![]() |
column-major layout代码 |
Hiding memory latency
- channel 与 bank:channel 是一种内存和 CPU 上的内存控制器,通过总线来和计算机的其余部分(比如 bank)进行通信。在内存和内存控制器之间增加通信通道,可以加快数据传输速率。例如下图中一个处理器上包含 4 个 channels,每个 channel 通过总线与 4 个 DRAM banks 相连。实际场景中 channels 的数量通常从 1 到 8 不等,bank 的数量则要远多于 4 个。每个 bank 中都有一组 DRAM cells、用来感知电荷数量的 sensing amplifiers 以及将数据传输到 bus 上的接口。
- bus 的 data transfer bandwidth:由 bus 的 width 和 clock frequency 决定。现代 double data rate (DDR) busses 在每个 clock cycle 内进行两次 data transfer。例如 64 bit、1 GHz clock frequency 的 DDR bus 的带宽就是 8 B * 2* 1 GHz=16 GB/s,而一般 CPU 要求 32 GB/,GPU 则是 256 GB/s
- 如何确定 banks 的数量:一般来说当 cell array access latency 和 data transfer time 两者的比值为 R 时,如果想要充分 bus 的数据传输带宽,那么至少需要 R+1 个 banks。原因有二:
- 更多的 bank 能减少 bank conflict 的概率,也就是 multiple simultaneous accesses targeting the same bank,更多的 bank 可以增加 data accesses 分配至多个 banks 上的概率
- cell array 的大小存在 latency&manufacturability 的 trade-off,这就限制了每个 bank 上 cells 的数量
- 有效利用 DRAM access bandwidth 要求尽可能多的线程同时访问数据
- 有效提升 execution throughput 要求尽可能高效地利用 DRAM 的 parallel structure,也就是 banks 和 channels,因为如果多个线程同时访问同个 channel 上的数据,那么 access throughput 和 device execution speed 就会大打折扣
Thread coarsening
- 不要在不必要时使用这种方法。并非所有 workloads 都要为并行计算付出诸如 redundant loading、redundant work、synchronization overhead 的代价,例如第二章中提到的向量加运算并不涉及并行处理不同元素(即向量中的每个元素只参与一次运算而不会同时参与多个运算)
- 不要过度使用这种方法以至于降低硬件资源的利用效率。从 transparent scalability 的角度来看 thread coarsening 会 reduce the amount of parallelism that is exposed to the hardware,特别是在 coarsening factor 设置过高的时候会导致 parallel execution resources being unutilized。最佳 coarsening factor 应该按照 device-specific&dataset-specific 的角度去设置
- 避免过度增加 resource consumption 而影响 occupancy。在 kernel 中使用 thread coarsening 可能会导致每个线程需要使用更多的 register 或每个 block 使用更多的 shared memory。Occupancy 降低带来的 performance penalty 可能比 coarsening 所带来的 performance benefit 影响更大
A checklist of optimizations
Chap7 Convolution
Parallel convolution: a basic algorithm
- Control flow divergence:当 2D 卷积进行矩阵边缘某些元素的计算时会因为 boundary condition 而不会进入上述代码中的 if 判断条件。一般情况是: large input arrays&small filters 下出现计算少部分 output elements 引发 control divergence,而一般图片尺寸与 filter 相比都比较大,所以其影响不会特别严重
- 无法达到计算吞吐量峰值:在上述代码段第十行,每加载 8 个字节(两个 float 值)只进行了 2 次运算(乘法与加法),所以 FLOPS 只有 0.25 OP/B,这个计算速度根本无法达到峰值。具体原理可参考 Chap5 Memory architecture and data locality 中的 FLOPS 与访存效率
Constant memory and caching
- 特点:filter size 通常很小;filter 的 contents 固定;所有线程都会访问 filter elements
- 优化思路:使用 constant memory(对 block 内的所有 threads 均可见)存储 filter 以避免 thread 访问冲突;
- 实现:假设 host code 已经对 filter 的初始化,则 kernel 只需将 host memory 上的 filter 直接拷贝至 device constant memory 上即可
- 在 kernel 外部使用 __constant__ float 申明 filter array,类似 C 语言中的 external declaration
- 使用 cudaMemcpyToSymble 函数将 host memory 上的 filter 直接拷贝至 device constant memory 上(该函数会将数据从 CPU 拷贝到常量内存中)
- 注意:constant memory 申明后无需作为变量传入 kernel 中,因为 kernel 会像访问 global memory 一样访问 constant memory
- Constant memory 对于 device 来说只读但是对于 host 是可读可写。Constant memory 和 global memory 一样都位于 DRAM 上。现代处理器为了尽量避免 memory bottleneck 会使用 on-chip cache memories 来保存常用数据,以减少直接访问 DRAM 的频率
- 由于 the indices for accessing F 与 thread indices 无关(这点从代码中也能看出),所以 constant caches 可以提供非常可观的访问带宽;并且 filter 因为比较小所以每次都可以放在 constant cache 中被获取,这样就不会占用 DRAM bandwidth
- constant memory 的获取方式不同于其它的 GPU 内存,对于 constant memory 来说,最佳获取方式是 warp 中的 32 个 threads 获取 constant memory 中的同一个地址。如果获取的地址不同的话,只能串行地服务这些获取请求了
Tiled convolution with halo cells
- Input tile 指计算 output tile(指针 P)所需的 input elements,下图中 input tile(指针 N)为左侧矩阵中的蓝色区域,output tile 为右侧矩阵中的绿色区域
- Tiled convolution algorithms 原理简单来说就是一个 block 中的所有 threads 首先集体将 input tile 中的元素加载到 shared memory 上,然后通过获取 shared memory 上的值来计算 output tile。需要注意在每个 dimension 上 input tile 的边长都要比 output tile 的边长要长
- 代码第 10 行和第 20 行:如果取到矩阵大小外的元素(ghost cell)则将 input tile 上对应位置的值设置为 0(col 和 row 计算出来有可能是负值,也有可能超过矩阵的 height 和 width)
- 代码第 15 行:每个线程完成对矩阵 N 中对应元素的加载后进行 barrier synchronization 来保证整个 input tile 都已经加载到 shared memory 中
- 代码第 17-18 行:output tile elements 的位置表示。减去 filter 半径是因为 input tile size 亚比 output tile size 大一圈,所以 threadIdx 并不是一一对应的关系
- 代码第 24-28 行:通过 filter 遍历 patch 来计算 output element
- 上面这段代码对应的运算强度计算如下。假设 filter 边长为 5,input tile 为 32,output tile 则为 28,则计算出来的 arithmetic-to-global memory access ratio 大约是 9.57 OP/B
- 不同 input tile size、filter size 下的 arithmetic-to-global memory access ratio,其中 bound 为通过(2FILTER_RADIUS+1)^2 * 2/4 计算出的 ratio upper bound。可以看出真实情况下的 ratio 基本上无法达到 upper bound,而且 small block&tile sizes 所能达到的 ratio 越小
Tiled convolution using caches for halo cells
Chap14 Sparse matrix computation
- 许多 sparse matrices 的 size 会很大,不利于求逆矩阵
- 逆矩阵中往往会包含许多额外的非零值(fill-ins)
- 求解线性方程组的常见算法:共轭梯度法
- Space efficiency (or compaction): the amount of memory capacity that is required to represent the matrix using the storage format
- Flexibility: the extent to which the storage format makes it easy to modify the matrix by adding or removing nonzeros Accessibility: the kinds of data that the storage format makes it easy to access Memory access efficiency: the extent to which the storage format enables an efficient memory access pattern for a particular computation (one facet of regularization)
- Load balance: the extent to which the storage format balances the load across different threads for a particular computation (another facet of regularization)
A simple SpMV kernel with the COO format
- Flexibility:从文件中读取数据时,非零数据出现的顺序并不是确定的(a file that does not provide the nonzeros in a particular order),使用 COO 是一个合适的选择;另外每次非零数据插入到这个表中时很简单,只需要加到末尾就可以
- Accessibility:给定一个非零值,获取对应的行列比较容易;而给定某个行或列去获取对应行或列中所有的非零值则不太容易
- 缺点:需要原子操作(the same output value is updated by multiple threads),但是该操作可以避免,只要让每一行都让同个线程来获取和操作
- 代码如下。注意第七行中的加法为原子操作,以防多个线程同时操作输出向量的同一行的元素
- 优点:比 COO 占用的空间更小(rowPtrs 的数量等于行数);
- 缺点:往 CSR 中加入新的非零值比较麻烦(无法直接插入末尾,而是要插入对应的行中);会产生比较严重的 control flow divergence(因为一个线程要执行的次数依赖于每一行中的非零值个数);给定某个列,获取该列中所有的非零元素也比较麻烦
- 代码
Improving memory coalescing with the ELL format
- 第 7 行和第 8 行所有线程进行了 coalesced memory access,因为所有元素是以 column-major order 排列的,adjacent threads 获取到的即为 adjacent memory locations(有些 GPU 架构对于 coalesced memory access 有着更为严格的 address alignment rules,所以在设计 ELL SPMV kernel 的时候可能需要调整来让每次迭代读取的是 specified alignment units,比如 64 个字节)
- ELL 因为进行了 padding 操作所以 space efficiency 比 CSR 要差,特别如果小部分行中只有个别是非零值
- 往 ELL 中加入元素比 CSR 要更灵活(只要将 padding 的值替换为新加的值即可)
- ELL kernel 仍存在 control divergence 的问题
Regulating padding with the hybrid ELL-COO format
Educing control divergence with the JDS format
- 比 ELL 有更好的 space efficiency
- Flexibility 不太行,因为添加非零元素可能会造成 rows 的重排序
- JDS 允许 coalesced manner 获取矩阵非零值
- effective at reducing control divergence
评论
发表评论