ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line@triton.jitdefelu_kernel(input_ptr, output_ptr, num_elements, BLOCK_SIZE: tl.constexpr):# Calculate the starting index for this block block_start = tl.program_id(0) * BLOCK_SIZE# Create an array of indices for this block block_indices = block_start + tl.arange(0, BLOCK_SIZE)[:]# Create a mask to ensure only valid indices are processed valid_mask = block_indices < num_elements# Load input values from the input pointer based on valid indices input_values = tl.load(input_ptr + block_indices, valid_mask)# Define the ELU parameters zero_value = 0.0# Threshold for ELU activation negative_mask = input_values < zero_value exp_values = tl.math.exp(input_values)# Define the ELU output shift one_value = 1.0 shifted_exp_values = exp_values - one_value output_values = tl.where(negative_mask, shifted_exp_values, input_values) # Store the computed output values back to the output pointer tl.store(output_ptr + block_indices, output_values, valid_mask)
即使在 Triton 中,有时也无法完全达到设备的峰值性能,因为该语言在处理共享内存和流多处理器(SMs)内的调度等低级细节方面存在限制。
Triton 的能力仅限于块及其在 SMs 之间的调度
。为了获得更深入的控制,你需要直接在 CUDA 中实现内核,在那里你将能够访问所有底层低级细节。
CUDA 方面,可以采用各种技术来提高内核的效率。这里仅介绍其中几个:
优化内存访问模式以降低延迟
、
使用共享内存存储频繁访问的数据
以及
管理线程工作负载以最小化空闲时间。
在深入 CUDA 示例之前,总结一下看到的工具,这些工具使我们能够编写内核代码以在 GPU 上执行指令:
PyTorch:简单但速度较慢
torch.compile:简单且速度快,但灵活性不足
Triton:更难,但更快、更灵活
CUDA:最难,但最快、最灵活(如果掌握得当)
下面讨论 CUDA 中最常见的优化技术之一:
优化内存访问
。GPU 的全局内存(在前面的图表中是最大的内存)相比缓存来说,延迟较高,带宽较低,这通常是大多数应用程序的主要瓶颈。
高效地访问全局内存的数据
可以极大地提高性能。
内存合并
为了有效利用全局内存的带宽,理解其架构至关重要。在CUDA设备中,全局内存是使用DRAM实现的。
内存归约(Memory coalescing)
利用 DRAM 在访问内存地址时
以突发或连续内存位置范围的形式提供数据
的特点。每次访问 DRAM 位置时,包括请求的位置在内的连续位置序列由 DRAM 芯片中的多个传感器并行读取。一旦读取,这些数据可以快速传输到处理器。在 CUDA 中,
归约 coalescing
利用这种突发行为,通过确保 warp 中的线程(32 个执行相同指令的线程,SIMD)访问连续的内存位置,以最大化内存访问效率。
例如,如果线程 0 访问位置 M,线程 1 访问 M + 1,线程 2 访问 M + 2,依此类推,GPU 硬件将这些请求
归约或合并
为一个大型、高效的 DRAM 突发访问请求,而不是单独处理每个访问。
以矩阵乘法为例。一个简单直接的实现方式是,每个线程计算输出矩阵的一个元素,如下:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line __global__ voidmatmul_naive(int M, int N, int K, constfloat *A, constfloat *B, float *C) {constuint x = blockIdx.x * blockDim.x + threadIdx.x;constuint y = blockIdx.y * blockDim.y + threadIdx.y; if (x < M && y < N) {float tmp = 0.0;for (int i = 0; i < K; ++i) { tmp += A[x * K + i] * B[i * N + y]; } C[x * N + y] = tmp; }}
以矩阵乘法为例,块中的每个线程可能需要从两个矩阵(如 A 和 B)中获取元素。如果每个线程
独立地从全局内存加载所需的行和列
,就会出现大量冗余加载,因为块中的多个线程会访问重叠的数据。相反,我们可以使用分块处理 Tiling,将 A 和 B 的一个块(或 Tile)一次性加载到共享内存中,让该块中的所有线程重复使用相同的共享数据。
在分块处理的方法中,每次迭代时,块内的所有线程协同工作,将两个 Tile(一个来自矩阵 A,另一个来自矩阵 B)加载到共享内存中。具体来说,线程加载矩阵 A 的一个Tile(大小为
BLOCK_SIZE_M
×
BLOCK_SIZE_K
)以及矩阵 B 的一个Tile(大小为
BLOCK_SIZE_K
×
BLOCK_SIZE_N
)。一旦这些Tile存入共享内存,线程就可以在这些Tile上执行矩阵乘法,从而实现高效计算,因为所有必要的数据都可以被快速访问。Tile乘法的结果存储在一个累积矩阵中,该矩阵保存中间结果。在每次迭代后,当前Tile乘法的结果都会累加到该矩阵中,直到两个矩阵的所有Tile都被处理完毕。
让我们来看看实现中的关键部分:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line // Set pointers to the starting elementsA += blockRow * TILE_SIZE * K; // Start at row = blockRow, column = 0B += blockCol * TILE_SIZE; // Start at row = 0, column = blockColC += blockRow * TILE_SIZE * N + blockCol * TILE_SIZE; // Start at row = blockRow, column = blockColfloat sum = 0.0;// The outer loop moves through tiles of A (across columns) and B (down rows)for (int tileIdx = 0; tileIdx < K; tileIdx += TILE_SIZE) {sharedA[localRow * TILE_SIZE + localCol] = A[localRow * K + localCol];sharedB[localRow * TILE_SIZE + localCol] = B[localRow * N + localCol]; // Ensure all threads in the block have completed data loading__syncthreads(); // Shift pointers to the next tileA += TILE_SIZE;B += TILE_SIZE * N; // Compute the partial dot product for this tilefor (int i = 0; i < TILE_SIZE; ++i) { sum += sharedA[localRow * TILE_SIZE + i] * sharedB[i * TILE_SIZE + localCol];}// Synchronize again to prevent any thread from loading new data// into shared memory before others have completed their calculations__syncthreads();}C[localRow * N + localCol] = sum;
每个线程首先从
矩阵 A
和
矩阵 B
中加载一个元素到共享内存。在这种情况下,实现合并内存访问(coalesced memory access)非常直观:通过将
threadIdx.x
作为
局部列索引(localCol)
,同一个 warp 中的线程可以访问相邻的矩阵元素。块内所有线程完成数据加载后(通过调用
__syncthreads()
确保同步),它们就会计算这两个Tile的点积。当所有Tile遍历完成——
矩阵 A
在水平方向移动,
矩阵 B
在垂直方向移动——最终计算出的结果存入
矩阵 C
的对应位置。
当warp内的线程采取不同的执行路径时,就会发生控制分歧
。例如,如果条件语句(如
if
语句)导致一些线程执行一个代码块,而其他线程执行另一个代码块,那么warp必须串行执行这些执行,导致空闲线程等待其他线程完成。为了最小化控制分歧,我们需要设计内核,**确保warp内的线程遵循相同的执行路径。这可以通过重构代码以减少分支、**使用确保所有线程遵循类似执行路径的数据结构,或使用预测等技术来实现。