Global Memory 合并访问
GPU L1 cache line 为 128 B(32 个 float)。一个 warp 的 32 个线程同时发出全局内存请求时,硬件会将它们合并成尽可能少的事务。若每个线程访问连续的 4 B 地址,整个 warp 只需 1 个 128 B 事务;若地址随机散乱,最坏情况触发 32 次独立事务,带宽利用率跌至 1/32。
AoS vs SoA 是合并访问最常见的陷阱。将 struct{float x,y,z;} pts[N] 改为分离的 float xs[N], ys[N], zs[N],warp 访问 xs 时天然连续。
// ❌ AoS — warp 访问 x 字段时步长 = 3 floats (12 B)
struct Point { float x, y, z; };
__global__ void bad_kernel(Point* pts, float* out, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) out[i] = pts[i].x; // 非合并
}
// ✅ SoA — warp 访问 xs 时步长 = 1 float,完美合并
struct PointSoA { float *xs, *ys, *zs; };
__global__ void good_kernel(PointSoA pts, float* out, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) out[i] = pts.xs[i]; // 合并访问
}
| ncu 指标 | 含义 | 健康值 |
|---|---|---|
l1tex__t_sectors_pipe_lsu_mem_global_op_ld | 实际触发的 sector 数(32 B/sector) | — |
l1tex__t_requests_pipe_lsu_mem_global_op_ld | warp 发出的请求数 | — |
| sectors / requests 比值 = 合并效率 | ≤ 4(理想 = 4) | |
| 若比值 > 4,说明存在非合并访问 | > 16 需重构 | |
ncu --metrics l1tex__t_sectors_pipe_lsu_mem_global_op_ld.avg.pct_of_peak_sustained_elapsed 确认是否带宽受限,再看 sectors/requests 定位根因。Shared Memory Bank Conflict
Shared memory 被划分为 32 个 bank,每个 bank 宽度 4 B(默认,可切换 8 B)。地址按 bank = (addr / 4) % 32 映射。同一 warp 内若多个线程访问同一 bank 的不同行,产生 bank conflict,硬件串行化这些访问,代价是 N-way conflict 耗时 N 倍。
矩阵转置是经典场景。列访问 shared mem 时步长=行宽,造成 32-way conflict。解法:在 shared mem 列维度加 1 列 padding。
// ❌ 无 padding — 列读取时 32-way bank conflict
const int TILE = 32;
__shared__ float tile[TILE][TILE];
// ✅ +1 padding 打破 bank 对齐
__shared__ float tile[TILE][TILE + 1];
// 内存布局改变后,原来 bank = col % 32 → bank = (col + row) % 32,均匀分布
__global__ void transpose(float* in, float* out, int W, int H) {
__shared__ float tile[TILE][TILE + 1];
int x = blockIdx.x * TILE + threadIdx.x;
int y = blockIdx.y * TILE + threadIdx.y;
if (x < W && y < H)
tile[threadIdx.y][threadIdx.x] = in[y * W + x]; // 合并写
__syncthreads();
x = blockIdx.y * TILE + threadIdx.x;
y = blockIdx.x * TILE + threadIdx.y;
if (x < H && y < W)
out[y * H + x] = tile[threadIdx.x][threadIdx.y]; // 合并读,无 conflict
}
| ncu 指标 | 说明 | 健康值 |
|---|---|---|
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld | Load bank conflicts | 0 |
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_st | Store bank conflicts | 0 |
| Nsight Compute: Memory → Shared Memory 面板可直接看 conflict 分布热图 | ||
Occupancy 与资源限制
Occupancy = 活跃 warp 数 / SM 最大 warp 数。高 occupancy 允许 SM 在内存延迟期间切换其他 warp,隐藏延迟。四类资源各自施加上限,实际 occupancy 取最小值。
寄存器使用量超过阈值时,编译器将寄存器溢出(register spilling)到 local memory(物理上是显存),带来严重性能惩罚。用 --maxrregcount 限制寄存器数可提高 occupancy,但可能引入溢出,需测量权衡。
// 查看每个线程的寄存器使用量
nvcc -Xptxas -v -arch=sm_80 kernel.cu
// 输出: ptxas info: Function properties for 'myKernel'
// Used 128 registers, 0 bytes smem, 0 bytes cmem[0]
// 限制寄存器强制提高 occupancy(风险:可能增加 spilling)
nvcc -maxrregcount 64 kernel.cu
// 或在代码层面精确控制
__launch_bounds__(256, 4) // maxThreadsPerBlock=256, minBlocksPerSM=4
__global__ void myKernel() { ... }
// ncu 查看溢出
ncu --metrics sm__sass_average_branch_targets_threads_uniform.pct,\
l1tex__t_sectors_pipe_lsu_mem_local_op_ld.sum kernel
| 资源 | A100 SM 上限 | 单 block 触发限制的阈值 |
|---|---|---|
| Warps | 64 warps | block > 2048 threads |
| Blocks | 32 blocks | block < 64 threads(需 >32 blocks) |
| Registers | 65536 regs | 每线程 >64 regs → <100% occ |
| Shared Mem | ~164 KB 可分配 | 每 block 使用量决定上限 |
Tiling 数据复用
Tiling 是将全局内存数据分块(tile)加载到 shared memory,让同一 block 的线程复用这块数据,减少对全局内存的访问次数。以 GEMM 为例:无 tiling 时每个输出元素需要 K 次全局内存读取;tile 大小为 T 时,每个元素只需 K/T 次全局内存事务,节省 T 倍带宽。
| 指标 | 无 Tiling | Tile 32×32 | 加速比 |
|---|---|---|---|
| 全局内存读次数 (GEMM 4096²) | 4096³ = 68.7 B | 4096³/32 = 2.15 B | 32× |
| 有效带宽利用率 | ~3% | ~96% | 32× |
| 全局内存延迟暴露 | 每次 ~500 cycles | 隐藏于 smem 访问 | ~80× |
| Shared memory 压力 | 0 | 2 × 32² × 4 B = 8 KB | — |
const int TILE = 32;
__global__ void gemm_tiled(
const float* __restrict__ A,
const float* __restrict__ B,
float* C, int M, int N, int K)
{
__shared__ float As[TILE][TILE];
__shared__ float Bs[TILE][TILE];
int row = blockIdx.y * TILE + threadIdx.y;
int col = blockIdx.x * TILE + threadIdx.x;
float acc = 0.0f;
for (int t = 0; t < (K + TILE - 1) / TILE; ++t) {
// 协作加载一个 tile 到 shared memory(合并访问)
As[threadIdx.y][threadIdx.x] =
(row < M && t * TILE + threadIdx.x < K)
? A[row * K + t * TILE + threadIdx.x] : 0.f;
Bs[threadIdx.y][threadIdx.x] =
(t * TILE + threadIdx.y < K && col < N)
? B[(t * TILE + threadIdx.y) * N + col] : 0.f;
__syncthreads();
// 从 shared memory 计算(无全局内存访问)
for (int k = 0; k < TILE; ++k)
acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
__syncthreads();
}
if (row < M && col < N) C[row * N + col] = acc;
}
__pipeline_memcpy_async + cp.async),让当前 tile 的计算与下一个 tile 的数据加载并行,几乎完全隐藏全局内存延迟,是 cuBLAS / CUTLASS 实现高峰值吞吐的核心技术之一。Warp Divergence
GPU 以 warp(32 线程) 为单位执行指令,所有线程执行相同的指令序列(SIMT)。当 warp 内线程遇到条件分支(if/switch)并走向不同路径时,硬件将两条路径串行执行,每次只激活满足条件的线程,其余线程空转(masked off)。
修复策略:让分支边界对齐 warp 边界(即分支以 32 的倍数为界),使每个 warp 内部不发生分叉;或使用 predication(无分支写法),让编译器生成 SELP 指令代替跳转。
// ❌ 分支边界不对齐 warp,每个 warp 都会 diverge
__global__ void bad_branch(float* data, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (data[i] > 0.5f) // 数据相关分支,无法对齐
data[i] *= 2.0f;
else
data[i] += 1.0f;
}
// ✅ Predication — 无分支,两条路径都算,最后 SELP 选择
__global__ void no_branch(float* data, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
float v = data[i];
float a = v * 2.0f; // 路径 A(全部计算)
float b = v + 1.0f; // 路径 B(全部计算)
data[i] = (v > 0.5f) ? a : b; // 编译器生成 SELP,无 diverge
}
// ✅ Warp-aligned 分支(不依赖数据,按线程 ID 分叉)
int lane = threadIdx.x % 32; // lane ID (0-31)
int warp = threadIdx.x / 32;
if (warp % 2 == 0) { ... } // 整个 warp 走同一路径,无 diverge
| ncu 指标 | 含义 | 健康值 |
|---|---|---|
sm__sass_average_branch_targets_threads_uniform.pct | 分支中线程一致执行的比例 | 100% |
smsp__thread_inst_executed_pred_on.sum | 实际执行的有效线程指令数 | — |
| Warp Execution Efficiency | = 活跃线程 / warp 总线程 | > 90% |
break)、函数调用深度不同也会造成 divergence。用 Nsight Compute 的 Source 视图可以精确到每条 SASS 指令的线程活跃数,快速定位热点分支。Launch 配置与 Tail Effect
GPU 将 block 分配给 SM 处理。若总 block 数不是 SM 数的整数倍,最后一"波(wave)"的 SM 利用率低于 100%,这就是 Tail Effect。例如 A100 有 108 个 SM,若总 block 数为 110,则前 108 个 block 满载运行,最后 2 个 block 只占 2/108 ≈ 1.9% 的 SM,其余 SM 空闲。
Persistent Kernel 是解决 tail effect 的终极方案:只 launch 恰好占满所有 SM 的 block 数,每个 block 在内部循环处理多个工作单元,消除 wave 边界。
// 计算最优 block 数(恰好占满 SM)
int sm_count;
cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, 0);
int blocks_per_sm;
cudaOccupancyMaxActiveBlocksPerMultiprocessor(
&blocks_per_sm, myKernel, BLOCK_SIZE, smem_bytes);
int num_blocks = sm_count * blocks_per_sm; // persistent block count
// Persistent kernel 模式
__global__ void persistent_kernel(float* data, int N) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
int stride = gridDim.x * blockDim.x; // 总线程数
// 每个线程处理多个元素,直到覆盖全部数据
for (int i = tid; i < N; i += stride) {
data[i] = process(data[i]);
}
}
// launch: persistent_kernel<<>>(data, N);
| Block Size | Warp 数/Block | A100 最大 Block/SM | 实际 Warp/SM | Occupancy | 推荐 |
|---|---|---|---|---|---|
| 64 | 2 | 32 | 64 | 100% | 可用 |
| 128 | 4 | 16 | 64 | 100% | ★ 推荐 |
| 256 | 8 | 8 | 64 | 100% | ★ 推荐 |
| 512 | 16 | 4 | 64 | 100% | 可用 |
| 1024 | 32 | 2 | 64 | 100% | 慎用 |
| 48(非 32 倍数) | 1.5 | — | — | — | 避免 |
cudaOccupancyMaxActiveBlocksPerMultiprocessor 动态计算最优 launch 参数,比硬编码 block 数更鲁棒。Persistent kernel 配合 work-stealing 队列是处理不规整任务的终极方案。