刚接触CUDA编程的朋友们,在跑通第一个向量加法示例后,往往会陷入一个误区:GPU编程不过如此嘛!但当你真正开始编写复杂的计算任务时,很快就会发现,内存管理才是GPU编程中最具挑战性的部分。
NVIDIA GPU上有四种主要内存类型:Global Memory、Shared Memory、Texture Memory和Unified Memory。每种内存都有其特定的访问模式、性能特征和使用场景。理解它们的差异并正确使用,是写出高性能CUDA代码的关键。
提示:在GPU编程中,错误的内存使用方式可能导致性能下降几十倍。有时候,一个简单的内存访问模式调整,就能带来显著的性能提升。
Global Memory是GPU上容量最大但访问延迟最高的内存。它类似于CPU上的主内存,但访问延迟高达几百个时钟周期。我们常用的cudaMalloc分配的就是Global Memory。
让我们以矩阵乘法(SGEMM)为例,看看Global Memory的基本使用方式:
c++复制// 矩阵乘法核函数 - 最朴素实现
__global__ void matrixMul(float *C, float *A, float *B, int width) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < width && col < width) {
float sum = 0.0f;
for (int k = 0; k < width; ++k) {
sum += A[row * width + k] * B[k * width + col];
}
C[row * width + col] = sum;
}
}
这个实现虽然正确,但性能会很差,原因就在于它对Global Memory的访问模式不理想。
GPU的Global Memory访问有几个关键特性需要考虑:
合并访问(Coalesced Access):连续的线程应该访问连续的内存地址,这样GPU可以将多个内存访问合并为一个更大的事务。
对齐访问:内存访问应该从对齐的地址开始(通常是32字节或128字节边界)。
内存事务大小:GPU总是以固定大小的块(通常是32字节、64字节或128字节)来传输数据,即使你只需要其中的一部分。
让我们优化上面的矩阵乘法实现:
c++复制// 优化后的矩阵乘法核函数
__global__ void matrixMulOptimized(float *C, float *A, float *B, int width) {
// 使用共享内存优化
__shared__ float As[TILE_SIZE][TILE_SIZE];
__shared__ float Bs[TILE_SIZE][TILE_SIZE];
int bx = blockIdx.x, by = blockIdx.y;
int tx = threadIdx.x, ty = threadIdx.y;
int row = by * TILE_SIZE + ty;
int col = bx * TILE_SIZE + tx;
float sum = 0.0f;
for (int m = 0; m < width / TILE_SIZE; ++m) {
// 协作加载数据到共享内存
As[ty][tx] = A[row * width + (m * TILE_SIZE + tx)];
Bs[ty][tx] = B[(m * TILE_SIZE + ty) * width + col];
__syncthreads();
for (int k = 0; k < TILE_SIZE; ++k) {
sum += As[ty][k] * Bs[k][tx];
}
__syncthreads();
}
if (row < width && col < width) {
C[row * width + col] = sum;
}
}
这个优化版本使用了共享内存(Shared Memory)来减少Global Memory的访问次数,我们将在下一节详细讨论共享内存。
Shared Memory是GPU上的一块高速内存,访问延迟比Global Memory低得多(大约快100倍)。它的容量较小(通常是每个SM几十KB),由同一个线程块内的所有线程共享。
Shared Memory最常见的用途包括:
让我们看一个使用Shared Memory优化归约(Reduction)操作的例子:
c++复制// 使用Shared Memory的归约操作
__global__ void reduce(int *input, int *output, int N) {
__shared__ int partialSum[256];
int tid = threadIdx.x;
int i = blockIdx.x * blockDim.x + threadIdx.x;
partialSum[tid] = (i < N) ? input[i] : 0;
__syncthreads();
// 在共享内存中进行归约
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
if (tid < s) {
partialSum[tid] += partialSum[tid + s];
}
__syncthreads();
}
// 写入结果
if (tid == 0) {
output[blockIdx.x] = partialSum[0];
}
}
Shared Memory被组织成多个bank(通常是32个)。当多个线程同时访问同一个bank的不同地址时,就会发生bank冲突,导致性能下降。
避免bank冲突的技巧:
Texture Memory是GPU上的一种特殊内存,最初设计用于图形处理,但在通用计算中也有其用途。它是一种只读内存,具有自动缓存和地址转换功能。
使用Texture Memory的基本步骤:
示例代码:
c++复制// 声明纹理引用
texture<float, 1, cudaReadModeElementType> texRef;
// 绑定纹理
cudaBindTexture(0, texRef, devPtr, size);
// 在核函数中使用
__global__ void kernel() {
float value = tex1Dfetch(texRef, index);
}
// 解绑纹理
cudaUnbindTexture(texRef);
Texture Memory特别适合以下场景:
Unified Memory是CUDA 6.0引入的特性,它提供了一个统一的内存地址空间,CPU和GPU都可以访问。底层由驱动程序自动管理数据的迁移。
使用Unified Memory的基本方法:
c++复制// 分配Unified Memory
float *data;
cudaMallocManaged(&data, size);
// CPU可以访问
for (int i = 0; i < N; i++) {
data[i] = i;
}
// GPU也可以访问
kernel<<<grid, block>>>(data);
// 同步确保GPU完成
cudaDeviceSynchronize();
// CPU可以继续访问
printf("%f\n", data[0]);
// 释放内存
cudaFree(data);
示例:
c++复制// 预取数据到GPU
cudaMemPrefetchAsync(data, size, deviceId);
// 设置访问建议
cudaMemAdvise(data, size, cudaMemAdviseSetPreferredLocation, deviceId);
| 内存类型 | 延迟 | 带宽 | 容量 | 访问范围 | 主要用途 |
|---|---|---|---|---|---|
| Global Memory | 高 | 中 | 大 | 所有线程 | 主要存储区域 |
| Shared Memory | 低 | 高 | 小 | 线程块内 | 数据共享和重用 |
| Texture Memory | 中 | 中 | 中 | 所有线程 | 具有空间局部性的只读数据 |
| Unified Memory | 可变 | 可变 | 大 | CPU和GPU | 简化编程模型 |
在实际应用中,通常会混合使用多种内存类型。例如:
cuda-memcheck工具检查cudaMalloc都有对应的cudaFreeassert检查关键条件printf,在GPU代码中使用printf(需要CUDA 4.0+)cudaMemcpyAsync进行异步内存拷贝在实际项目中,我发现最有效的优化往往来自于对内存访问模式的深入理解,而不是复杂的算法改动。一个简单的内存布局调整,有时能带来惊人的性能提升。特别是在处理大规模数据时,合理使用Shared Memory和Texture Memory可以显著减少对Global Memory的访问压力。