GPU 性能调优指南

CUDA 优化六要

从内存层次到调度策略,系统梳理 GPU kernel 性能调优的六个核心维度。每个方向配有原理、量化指标与可落地的代码修复。

CUDA C++ Nsight Compute Hopper / Ampere
阅读约 12 分钟
01

Global Memory 合并访问

Cache line coalescing · 128 B / 32 B transaction · sectors/requests

GPU L1 cache line 为 128 B(32 个 float)。一个 warp 的 32 个线程同时发出全局内存请求时,硬件会将它们合并成尽可能少的事务。若每个线程访问连续的 4 B 地址,整个 warp 只需 1 个 128 B 事务;若地址随机散乱,最坏情况触发 32 次独立事务,带宽利用率跌至 1/32。

访问模式对比 — 每行 = 一个 warp (32 线程)
合并访问
T0
T1
T2
T3
T4
T5
T6
T7
T8
T9
T31
→ 1 transaction, 128 B
步长=2 访问
T0
T1
T2
T3
T4
→ 2 transactions, 50% 浪费
随机访问
T7
T2
T19
T0
T28
T14
T3
T21
T9
T17
→ 最坏 32 transactions

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_ldwarp 发出的请求数
sectors / requests 比值 = 合并效率≤ 4(理想 = 4)
若比值 > 4,说明存在非合并访问> 16 需重构
🔑
关键认知: 带宽不是瓶颈,事务数才是。A100 理论带宽 2 TB/s,但非合并访问会把有效带宽压到 <100 GB/s。先用 ncu --metrics l1tex__t_sectors_pipe_lsu_mem_global_op_ld.avg.pct_of_peak_sustained_elapsed 确认是否带宽受限,再看 sectors/requests 定位根因。
02

Shared Memory Bank Conflict

32 banks · 4 B/bank · padding 修复 · ncu bank_conflicts

Shared memory 被划分为 32 个 bank,每个 bank 宽度 4 B(默认,可切换 8 B)。地址按 bank = (addr / 4) % 32 映射。同一 warp 内若多个线程访问同一 bank 的不同行,产生 bank conflict,硬件串行化这些访问,代价是 N-way conflict 耗时 N 倍。

Bank 访问模式 — 8 线程示意 (实际 32 线程)
✓ 无冲突(连续访问)
B0
B1
B2
B3
B4
B5
B6
B7
T0
T1
T2
T3
T4
T5
T6
T7
每线程访问不同 bank → 并行
✗ 2-way Conflict(步长=2)
B0
B1
B0
B1
B0
B1
B0
B1
T0
T1
T2
T3
T4
T5
T6
T7
T0/T2/T4/T6 同打 B0 → 串行 4 次

矩阵转置是经典场景。列访问 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_ldLoad bank conflicts0
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_stStore bank conflicts0
Nsight Compute: Memory → Shared Memory 面板可直接看 conflict 分布热图
⚠️
广播例外: 若同一 warp 所有线程访问完全相同的地址(同一 bank 同一行),硬件执行广播(broadcast),无 conflict,仅耗 1 个周期。只有地址不同但落入同一 bank 才产生冲突。
03

Occupancy 与资源限制

四维资源 · 寄存器溢出 · Waves · 最优 block size

Occupancy = 活跃 warp 数 / SM 最大 warp 数。高 occupancy 允许 SM 在内存延迟期间切换其他 warp,隐藏延迟。四类资源各自施加上限,实际 occupancy 取最小值。

A100 SM 资源限制 — 各资源独立决定 occupancy 上限
线程数限制
2048 / 2048
100%
Block 数限制
32 / 32
100%
寄存器数限制
128 regs/thread
50%
Shared Memory
48 KB / 64 KB
75%
实际 Occupancy
受寄存器限制
50%

寄存器使用量超过阈值时,编译器将寄存器溢出(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 触发限制的阈值
Warps64 warpsblock > 2048 threads
Blocks32 blocksblock < 64 threads(需 >32 blocks)
Registers65536 regs每线程 >64 regs → <100% occ
Shared Mem~164 KB 可分配每 block 使用量决定上限
🔑
关键认知: 高 occupancy ≠ 高性能。对于计算密集型 kernel(如 GEMM),50% occupancy 常常足够。追求 100% occupancy 而强制限制寄存器反而会因寄存器溢出造成吞吐下降。先用 roofline 确认 kernel 是 compute-bound 还是 memory-bound,再决定是否需要调整 occupancy。
04

Tiling 数据复用

GEMM tiling · 32× 带宽节省 · 80× 延迟遮蔽

Tiling 是将全局内存数据分块(tile)加载到 shared memory,让同一 block 的线程复用这块数据,减少对全局内存的访问次数。以 GEMM 为例:无 tiling 时每个输出元素需要 K 次全局内存读取;tile 大小为 T 时,每个元素只需 K/T 次全局内存事务,节省 T 倍带宽

指标无 TilingTile 32×32加速比
全局内存读次数 (GEMM 4096²)4096³ = 68.7 B4096³/32 = 2.15 B32×
有效带宽利用率~3%~96%32×
全局内存延迟暴露每次 ~500 cycles隐藏于 smem 访问~80×
Shared memory 压力02 × 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;
}
💡
进阶:Double Buffering。 用两套 shared mem tile 交替使用(__pipeline_memcpy_async + cp.async),让当前 tile 的计算与下一个 tile 的数据加载并行,几乎完全隐藏全局内存延迟,是 cuBLAS / CUTLASS 实现高峰值吞吐的核心技术之一。
🔑
关键认知: Tiling 的本质是用 shared memory 延迟来替换全局内存延迟。Shared memory 访问只需 ~32 cycles,全局内存 ~500 cycles。tile 大小选 32×32 通常最佳——更大的 tile 节省更多带宽,但占用更多 shared mem 进而压低 occupancy。
05

Warp Divergence

SIMT 串行化 · predicate 消除 · 按 warp 边界对齐

GPU 以 warp(32 线程) 为单位执行指令,所有线程执行相同的指令序列(SIMT)。当 warp 内线程遇到条件分支(if/switch)并走向不同路径时,硬件将两条路径串行执行,每次只激活满足条件的线程,其余线程空转(masked off)。

Warp 执行时间线 — 分支导致序列化
每格 = 4 个时钟周期,每行 = 一条执行路径
无分支
T0-T31 全部执行(4 cycles)
if (tid<16)
T0-T15
T16-T31 idle
else
T0-T15 idle
T16-T31
总耗时翻倍,warp 效率 = 50%

修复策略:让分支边界对齐 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%
ℹ️
Ampere+ 改进: Ampere 架构引入独立线程调度(Independent Thread Scheduling),每个线程有独立的 PC 和调用栈,可以在 warp 内以更细粒度调度,但 SIMT 执行模型依然存在,divergence 的性能代价没有消除,只是在某些 producer-consumer 场景下更灵活。
🔑
关键认知: Divergence 不仅存在于 if/else,循环的提前退出(break)、函数调用深度不同也会造成 divergence。用 Nsight Compute 的 Source 视图可以精确到每条 SASS 指令的线程活跃数,快速定位热点分支。
06

Launch 配置与 Tail Effect

SM 利用率 · Persistent Kernel · Block size 选择表

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 空闲。

SM 利用率 — Wave 分配示意(共 4 SM,5 个 Block)
SM0
SM1
SM2
SM3
Wave 1: SM 0-3 各跑 1 个 Block(100% 利用率)
SM0
SM1
SM2
SM3
■ 第 5 个 Block(仅 SM0) ■ SM1-3 空闲
Tail wave SM 利用率 = 25%,整体利用率 = (4+1)/(4×2) = 62.5%

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 SizeWarp 数/BlockA100 最大 Block/SM实际 Warp/SMOccupancy推荐
6423264100%可用
12841664100%★ 推荐
2568864100%★ 推荐
51216464100%可用
102432264100%慎用
48(非 32 倍数)1.5避免
⚠️
Block size 必须是 32 的倍数。 非 32 倍数的 block size 会造成最后一个 warp 部分线程永久空转(inactive threads),浪费计算资源且无法被 occupancy 计算正确捕捉。256 或 128 是大多数 kernel 的最佳起点。
🔑
关键认知: Tail effect 在 batch size 小或数据规模不规整时特别明显。对于推理场景(batch=1 或 latency-critical),用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 动态计算最优 launch 参数,比硬编码 block 数更鲁棒。Persistent kernel 配合 work-stealing 队列是处理不规整任务的终极方案。