内存优化
内存优化核心原则
- 最大化全局内存带宽利用率(合并访存、向量化)
- 最小化全局内存访问次数(共享内存缓存、寄存器重用)
- 避免共享内存 Bank Conflict(padding、swizzle 地址映射)
全局内存合并访问
合并访存原理
在 GPU 编程中,合并访存是指线程束中的线程按照特定规则访问全局内存时,硬件能够将这些分散的内存请求合并为更少的内存事务(transaction),从而显著提高内存访问效率。
MT GPU S5000 的合并机制:
- 一个线程束包含 32 个线程
- 每 4 个或 8 个线程为粒度,经过合并单元聚合
- 聚合为多个 128B 的请求(L2 缓存行大小)
- 内存子系统下游均以 128B 事务为单位处理
合并访存规则
完全合并访问(推荐):
// ✅ Good: 完全合并访问
// 线程 0,1,2,3... 访问地址 0,1,2,3...(连续对齐)
__global__ void coalescedAccess(float* data) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
data[idx] = data[idx] * 2.0f;
}
非合并访问(避免):
// ❌ Bad: 跨步访问,带宽利用率极低
// 线程 0,1,2,3... 访问地址 0,1024,2048,3072...
__global__ void stridedAccess(float* data, int stride) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
data[idx * stride] = data[idx * stride] * 2.0f;
}
// 带宽利用率:4B/128B = 1/32 ≈ 3%
访存模式对比
| 访问模式 | 描述 | 事务数 | 带宽利用率 |
|---|---|---|---|
| 连续 32 线程访问连续 32 地址 | 完全合并 | 1 个事务 | 100% |
| 连续 32 线程访问间隔 2 地址 | 2-way 跨步 | 2 个事务 | 50% |
| 连续 32 线程访问间隔 4 地址 | 4-way 跨步 | 4 个事务 | 25% |
| 随机访问 | 无规律 | 32 个事务 | ~3% |
向量化访存
为什么需要向量化?
即使满足合并访存,固定的访问数据量会产生固定数量的 128B 事务(transaction)。但向量化访存仍有以下优势:
- 减少访存指令数量:一条
float4指令替代 4 条float指令 - 降低 LSU(加载/存储单元):Load/Store Unit 处理能力有上限,指令过多会导致瓶颈
- 提高带宽利用率:MT GPU S5000 支持最大 1024bit 单指令访问
向量化访存示例
// ✅ 使用 float4 向量化加载(128bit)
__global__ void vectorLoad(float* in, float* out, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// 将 float 指针转换为 float4 指针
float4* in_vec = (float4*)in;
float4* out_vec = (float4*)out;
// 一次加载 4 个 float
float4 v = in_vec[idx];
// 计算
v.x = v.x * 2.0f;
v.y = v.y * 2.0f;
v.z = v.z * 2.0f;
v.w = v.w * 2.0f;
// 一次存储 4 个 float
out_vec[idx] = v;
}
// 启动配置(线程数减少为 1/4)
int n = 4096;
int threads = n / 4; // 每个线程处理 4 个元素
VectorLoad<<<1, threads>>>(d_in, d_out, n);
向量化类型选择
| 数据类型 | 单指令加载 | 适用场景 |
|---|---|---|
float | 32-bit | 基础类型,灵活性高 |
float2 | 64-bit | 小型向量运算 |
float4 | 128-bit | 推荐,平衡性能与灵活性 |
int4 | 128-bit | INT8 量化场景 |
共享内存存储体冲突消除
存储 体冲突(Bank Conflict)原理
共享内存分为 32 个存储体(bank),每个存储体宽度为 4 字节(32 位)。
存储体冲突(Bank Conflict):
- 同一线程束(Warp)中多个线程访问同一存储体的不同地址 → 序列化访问
- 同一线程束(Warp)中多个线程访问同一存储体的同一地址 → 广播(无冲突)
存储体冲突(Bank Conflict)示例
// ❌ Bad: 32 路存储体冲突
// 所有线程访问第 0 列 → 所有线程访问 bank 0
__shared__ float matrix[32][32];
__global__ void badAccess() {
int col = 0;
float value = matrix[threadIdx.x][col]; // 32 路冲突!
}
// 性能下降:串行为 32 次访问
// ✅ Good: 无冲突访问
// 每个线程访问不同 bank
__shared__ float matrix[32][32];
__global__ void goodAccess() {
int row = 0;
float value = matrix[row][threadIdx.x]; // 无冲突
}
解决方案 1:填充(Padding)(简单但浪费容量)
// ❌ 32×32 数组,列访问冲突
__shared__ float sharedA[32][32];
value = sharedA[threadIdx.x][col]; // 32 路冲突
// ✅ 添加 padding,32×33 数组
__shared__ float sharedB[32][33]; // 每行多 1 个元素
value = sharedB[threadIdx.x][col]; // 无冲突
// 原理:偏移了 bank 映射,打破对齐
// 缺点:浪费 32 * 4B = 128B 共享内存
解决方案 2:交错映射(Swizzle)(推荐)
交错映射(Swizzle)通过地址哈希(hash)映射,将连续地址分散到不同存储体(bank):
原始地址 → Bank 映射(线性):
地址 0,1,2,3 → Bank 0,1,2,3
地址 4,5,6,7 → Bank 0,1,2,3 (重复)
Swizzle 地址映射(哈希):
地址 0,1,2,3 → Bank 0,8,16,24
地址 4,5,6,7 → Bank 1,9,17,25 (分散)
MT GPU 硬件支持:
- MT GPU S5000 及后续架构支持硬件交错映射(swizzle)
- 无需手动填充(padding),编译器自动优化
- 通过访问模式触发(编译器检测)
代码示例:
// 编译器自动优化 swizzle 的场景
__shared__ float smem[256];
__global__ void swizzleOptimized() {
// 跨步访问可能触发 swizzle
int idx = (threadIdx.x * 33) % 256;
float val = smem[idx];
}
存储体冲突(Bank Conflict)检查清单
- 2D 数组列访问:检查是否所有线程访问同一列
- 动态索引:避免
smem[threadIdx.x * stride]模式 - 填充(Padding):容量充足时使用填充
- 交错映射(Swizzle):利用硬件自动优化
- 广播场景:同一地址访问无需担心冲突