06-CUDA高级优化¶
本章是GPU并行计算模块的核心进阶篇,覆盖Tensor Core编程、Stream异步执行、性能分析方法论、高性能GEMM五步优化、Warp级原语、内存优化进阶等NVIDIA/字节AML面试高频考点。所有代码均为完整可编译的CUDA C++,附nvcc编译命令。
6.1 Tensor Core编程¶
6.1.1 Tensor Core硬件架构演进¶
Tensor Core是NVIDIA从Volta架构开始引入的专用矩阵运算单元,能够在单个时钟周期内完成小型矩阵的乘加(MMA)运算。
| 架构 | 代表GPU | Tensor Core代次 | 支持精度 | 单次MMA操作 |
|---|---|---|---|---|
| Volta (2017) | V100 | 第1代 | FP16×FP16+FP32 | 4×4×4 MMA |
| Turing (2018) | RTX 2080 Ti | 第2代 | +INT8, INT4, INT1 | 同上 |
| Ampere (2020) | A100 | 第3代 | +TF32, BF16, FP64 | 扩展MMA形状 |
| Hopper (2022) | H100 | 第4代 | +FP8 (E4M3/E5M2) | wgmma异步MMA |
| Blackwell (2024) | B200 | 第5代 | +FP4, 微缩Tensor Core | 更大MMA形状 |
关键演进脉络:
-
Volta (第1代):每个SM包含8个Tensor Core,每个Tensor Core每周期执行一个 \(D = A \times B + C\) 操作,其中A、B为FP16的4×4矩阵,C、D为FP16或FP32的4×4矩阵。V100的峰值Tensor TFLOPS ≈ 125 TFLOPS(FP16)。
-
Ampere (第3代):引入TF32(19位浮点,8位指数+10位尾数+1位符号),在不修改代码的情况下将FP32矩阵乘性能提升到接近FP16水平。同时引入稀疏化支持(2:4结构化稀疏),理论性能再翻倍。A100峰值达312 TFLOPS(TF32)。
-
Hopper (第4代):引入Tensor Memory Accelerator (TMA),支持异步数据搬运;引入
wgmma指令,支持跨Warp Group的128×128×16操作;新增FP8精度,H100峰值近2000 TFLOPS(FP8稀疏)。同时引入Thread Block Cluster和分布式共享内存。
6.1.2 WMMA API编程¶
CUDA提供nvcuda::wmma API(Warp-level Matrix Multiply-Accumulate),让开发者在Warp级别操作Tensor Core。一个Warp(32个线程)协作完成一次矩阵乘加操作:
支持的矩阵形状(m×n×k): - 16×16×16(最常用,FP16) - 32×8×16、8×32×16(Volta/Turing) - Ampere起支持更多形状
WMMA编程步骤:
6.1.3 完整代码:WMMA实现16×16矩阵乘法¶
// file: wmma_matmul.cu
// 使用WMMA API实现16x16矩阵乘法:D = A * B + C
// 编译: nvcc -arch=sm_70 -o wmma_matmul wmma_matmul.cu
// (sm_70=Volta, sm_80=Ampere, sm_90=Hopper)
#include <cuda_runtime.h>
#include <mma.h> // WMMA头文件
#include <cuda_fp16.h> // half精度
#include <cstdio>
#include <cstdlib>
#include <cmath>
using namespace nvcuda;
// ---- 矩阵维度:必须满足Tensor Core对齐要求 ----
constexpr int M = 16;
constexpr int N = 16;
constexpr int K = 16;
// ---- WMMA Kernel ----
__global__ void wmma_matmul_kernel(const half *A, const half *B,
float *C, float *D,
int m, int n, int k) {
// 声明fragment
// A: 行主序(row_major),B: 列主序(col_major) —— WMMA要求
wmma::fragment<wmma::matrix_a, M, N, K, half, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, M, N, K, half, wmma::col_major> b_frag;
wmma::fragment<wmma::accumulator, M, N, K, float> acc_frag;
wmma::fragment<wmma::accumulator, M, N, K, float> c_frag;
// 加载A(m×k,行主序,leading dimension = k)
wmma::load_matrix_sync(a_frag, A, K);
// 加载B(k×n,列主序,leading dimension = k)
wmma::load_matrix_sync(b_frag, B, K);
// 加载偏置/累加器C(m×n,行主序,leading dimension = n)
wmma::load_matrix_sync(c_frag, C, N, wmma::mem_row_major);
// 执行MMA:acc = A * B + C
wmma::mma_sync(acc_frag, a_frag, b_frag, c_frag);
// 存储结果D(m×n,行主序)
wmma::store_matrix_sync(D, acc_frag, N, wmma::mem_row_major);
}
// ---- CPU参考实现 ----
void cpu_matmul(const float *A, const float *B, const float *C,
float *D, int m, int n, int k) {
for (int i = 0; i < m; ++i)
for (int j = 0; j < n; ++j) {
float sum = C[i * n + j];
for (int p = 0; p < k; ++p)
sum += A[i * k + p] * B[p * n + j];
D[i * n + j] = sum;
}
}
int main() {
// ---- 主机端数据准备 ----
float h_A_fp32[M * K], h_B_fp32[K * N];
float h_C[M * N], h_D_gpu[M * N], h_D_cpu[M * N];
srand(42);
for (int i = 0; i < M * K; ++i) h_A_fp32[i] = (float)(rand() % 5);
for (int i = 0; i < K * N; ++i) h_B_fp32[i] = (float)(rand() % 5);
for (int i = 0; i < M * N; ++i) h_C[i] = 0.0f; // C初始化为0
// 转FP16(用于GPU Tensor Core输入)
half h_A_fp16[M * K], h_B_fp16[K * N];
for (int i = 0; i < M * K; ++i) h_A_fp16[i] = __float2half(h_A_fp32[i]);
// 注意:B需要转为列主序存储给WMMA
for (int i = 0; i < K; ++i)
for (int j = 0; j < N; ++j)
h_B_fp16[j * K + i] = __float2half(h_B_fp32[i * N + j]); // 转置
// ---- 设备端内存分配 ----
half *d_A, *d_B;
float *d_C, *d_D;
cudaMalloc(&d_A, M * K * sizeof(half));
cudaMalloc(&d_B, K * N * sizeof(half));
cudaMalloc(&d_C, M * N * sizeof(float));
cudaMalloc(&d_D, M * N * sizeof(float));
cudaMemcpy(d_A, h_A_fp16, M * K * sizeof(half), cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B_fp16, K * N * sizeof(half), cudaMemcpyHostToDevice);
cudaMemcpy(d_C, h_C, M * N * sizeof(float), cudaMemcpyHostToDevice);
// ---- 启动Kernel(必须一个完整Warp = 32线程)----
wmma_matmul_kernel<<<1, 32>>>(d_A, d_B, d_C, d_D, M, N, K);
cudaDeviceSynchronize();
cudaMemcpy(h_D_gpu, d_D, M * N * sizeof(float), cudaMemcpyDeviceToHost);
// ---- CPU验证 ----
cpu_matmul(h_A_fp32, h_B_fp32, h_C, h_D_cpu, M, N, K);
float max_err = 0.0f;
for (int i = 0; i < M * N; ++i) {
float err = fabsf(h_D_gpu[i] - h_D_cpu[i]);
if (err > max_err) max_err = err;
}
printf("Max error between GPU(Tensor Core) and CPU: %f\n", max_err);
if (max_err < 1e-2f)
printf("PASSED! Tensor Core result matches CPU.\n");
else
printf("FAILED! Max error too large.\n");
// ---- 打印部分结果 ----
printf("\nGPU result (first 4x4):\n");
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j)
printf("%8.1f", h_D_gpu[i * N + j]);
printf("\n");
}
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C); cudaFree(d_D);
return 0;
}
编译与运行:
6.1.4 Tensor Core使用条件与注意事项¶
| 条件 | 要求 |
|---|---|
| 矩阵尺寸 | 必须为WMMA支持的形状(如16×16×16) |
| 数据精度 | 输入:FP16/BF16/TF32/INT8等;累加:FP32/INT32 |
| 内存对齐 | 矩阵首地址需要256字节对齐(性能最优) |
| Leading Dimension | 必须为16的倍数 |
| Warp级操作 | 必须32线程一起调用,不能有线程分支 |
| 架构要求 | 最低Volta (sm_70) |
6.1.5 面试考点¶
Q1: Tensor Core与普通CUDA Core有什么区别? A: CUDA Core每周期执行一次标量FMA(\(a \times b + c\));Tensor Core每周期执行一次矩阵MMA(如4×4×4的FP16矩阵乘+FP32累加),吞吐量高一个数量级。Tensor Core是专用硬件单元,需要使用WMMA/MMA PTX指令才能触发。
Q2: WMMA中fragment的含义是什么? A: fragment是分布在一个Warp的32个线程中的矩阵数据抽象。一个16×16的矩阵被拆分到32个线程的寄存器中,每个线程持有若干元素,具体映射由硬件决定(对程序员透明)。
Q3: TF32是什么?为什么Ampere引入它? A: TF32使用8位指数(与FP32相同范围)+10位尾数(与FP16相同精度),总计19位。它让FP32输入的矩阵乘无需手动转FP16即可利用Tensor Core,在保持FP32训练逻辑不变的前提下获得巨大加速。cuBLAS在Ampere上默认开启TF32用于GEMM。
6.2 CUDA Stream与异步执行¶
6.2.1 Stream概念¶
CUDA Stream是GPU上操作的有序队列。同一Stream内的操作按FIFO顺序执行;不同Stream中的操作可以并发执行(如果硬件资源允许)。
Stream 0 (默认): [H2D_A] → [Kernel_A] → [D2H_A]
Stream 1: [H2D_B] → [Kernel_B] → [D2H_B]
↑ 不同Stream之间可重叠
默认Stream(Stream 0 / Legacy Default Stream): - 所有未指定Stream的CUDA操作进入默认Stream - 默认Stream会与其它Stream隐式同步(除非使用--default-stream per-thread编译选项) - 这是常见的性能陷阱!
6.2.2 多Stream并发原理¶
现代GPU有独立的Copy Engine和Compute Engine:
时间轴 →
Copy Engine: [H2D_0][H2D_1][D2H_0][H2D_2][D2H_1][D2H_2]
Compute Engine: [Ker_0][Ker_1] [Ker_2]
↑ 计算与传输重叠 = Pipeline
要实现重叠,必须满足: 1. 使用不同的Stream 2. 使用异步API(如cudaMemcpyAsync) 3. Host内存使用Pinned Memory(cudaMallocHost或cudaHostAlloc)
6.2.3 CUDA Event¶
Event用于计时和Stream间同步:
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start, stream);
myKernel<<<grid, block, 0, stream>>>(...);
cudaEventRecord(stop, stream);
cudaEventSynchronize(stop);
float ms = 0;
cudaEventElapsedTime(&ms, start, stop); // 精确GPU计时
6.2.4 完整代码:多Stream Pipeline¶
// file: multi_stream_pipeline.cu
// 演示多Stream实现H2D + Compute + D2H三阶段Pipeline重叠
// 编译: nvcc -arch=sm_70 -o multi_stream multi_stream_pipeline.cu
#include <cuda_runtime.h>
#include <cstdio>
#include <cstdlib>
// 简单Kernel:向量逐元素乘以常数
__global__ void scale_kernel(float *data, float factor, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
// 模拟较重计算
float val = data[idx];
for (int i = 0; i < 100; ++i)
val = val * factor + 0.001f;
data[idx] = val;
}
}
int main() {
const int NUM_STREAMS = 4;
const int CHUNK_SIZE = 1 << 20; // 每个chunk 1M个float ≈ 4MB
const int TOTAL_SIZE = NUM_STREAMS * CHUNK_SIZE;
const float FACTOR = 1.0001f;
// ---- 分配Pinned Host内存(必须!否则无法真正异步)----
float *h_data;
cudaMallocHost(&h_data, TOTAL_SIZE * sizeof(float));
// 初始化数据
for (int i = 0; i < TOTAL_SIZE; ++i)
h_data[i] = (float)(i % 1000) * 0.001f;
// ---- 分配Device内存 ----
float *d_data;
cudaMalloc(&d_data, TOTAL_SIZE * sizeof(float));
// ---- 创建Stream和Event ----
cudaStream_t streams[NUM_STREAMS];
for (int i = 0; i < NUM_STREAMS; ++i)
cudaStreamCreate(&streams[i]);
cudaEvent_t start_event, stop_event;
cudaEventCreate(&start_event);
cudaEventCreate(&stop_event);
// ======== 方式1: 单Stream(无重叠,作为基准)========
cudaEventRecord(start_event, 0);
cudaMemcpy(d_data, h_data, TOTAL_SIZE * sizeof(float),
cudaMemcpyHostToDevice);
int threads = 256;
int blocks = (TOTAL_SIZE + threads - 1) / threads;
scale_kernel<<<blocks, threads>>>(d_data, FACTOR, TOTAL_SIZE);
cudaMemcpy(h_data, d_data, TOTAL_SIZE * sizeof(float),
cudaMemcpyDeviceToHost);
cudaEventRecord(stop_event, 0);
cudaEventSynchronize(stop_event);
float single_ms = 0;
cudaEventElapsedTime(&single_ms, start_event, stop_event);
printf("Single Stream: %.3f ms\n", single_ms);
// 重新初始化
for (int i = 0; i < TOTAL_SIZE; ++i)
h_data[i] = (float)(i % 1000) * 0.001f;
// ======== 方式2: 多Stream Pipeline(H2D+Compute+D2H重叠)========
cudaEventRecord(start_event, 0);
int chunk_blocks = (CHUNK_SIZE + threads - 1) / threads;
for (int i = 0; i < NUM_STREAMS; ++i) {
int offset = i * CHUNK_SIZE;
size_t bytes = CHUNK_SIZE * sizeof(float);
// 阶段1: 异步H2D
cudaMemcpyAsync(d_data + offset, h_data + offset, bytes,
cudaMemcpyHostToDevice, streams[i]);
// 阶段2: 异步Kernel执行
scale_kernel<<<chunk_blocks, threads, 0, streams[i]>>>(
d_data + offset, FACTOR, CHUNK_SIZE);
// 阶段3: 异步D2H
cudaMemcpyAsync(h_data + offset, d_data + offset, bytes,
cudaMemcpyDeviceToHost, streams[i]);
}
cudaEventRecord(stop_event, 0);
// 等待所有Stream完成
for (int i = 0; i < NUM_STREAMS; ++i)
cudaStreamSynchronize(streams[i]);
cudaEventSynchronize(stop_event);
float multi_ms = 0;
cudaEventElapsedTime(&multi_ms, start_event, stop_event);
printf("Multi Stream (%d streams): %.3f ms\n", NUM_STREAMS, multi_ms);
printf("Speedup: %.2fx\n", single_ms / multi_ms);
// ---- 清理 ----
for (int i = 0; i < NUM_STREAMS; ++i)
cudaStreamDestroy(streams[i]);
cudaEventDestroy(start_event);
cudaEventDestroy(stop_event);
cudaFree(d_data);
cudaFreeHost(h_data);
return 0;
}
编译与运行:
预期输出(GPU不同结果不同,但多Stream应更快):
6.2.5 面试考点¶
Q1: 为什么
cudaMemcpy(同步)不能实现传输与计算重叠? A:cudaMemcpy是阻塞调用,CPU会等待传输完成才返回,所有操作变成串行。必须使用cudaMemcpyAsync+ Pinned Memory + 非默认Stream才能实现真正的异步重叠。Q2: 默认Stream为什么是性能陷阱? A: Legacy默认Stream会与所有其它Stream隐式同步——当默认Stream中有操作时,其它Stream必须等待它完成。解决方案:① 总是创建显式Stream;② 使用
--default-stream per-thread编译,使每个CPU线程有独立默认Stream。Q3: 多Stream一定比单Stream快吗? A: 不一定。如果Kernel已经完全占满GPU算力(compute-bound),多Stream无法并行更多Kernel。多Stream主要收益在于重叠数据传输和计算。小数据量时Stream创建/调度的开销可能抵消收益。
6.3 性能分析方法论¶
6.3.1 Roofline Model¶
Roofline Model是判断Kernel性能瓶颈的核心工具。它可视化了算力(FLOPS)和带宽(Bytes/s)之间的关系。
计算强度(Operational Intensity):
性能上界:
判断规则: - \(OI < OI_{ridge}\)(拐点):Memory-bound,优化内存访问(合并、缓存、prefetch) - \(OI > OI_{ridge}\):Compute-bound,优化计算(Tensor Core、指令级并行)
常见Kernel的计算强度:
| Kernel | OI (FLOP/Byte) | 瓶颈类型 |
|---|---|---|
| 向量加法 | 0.25 | Memory-bound |
| 矩阵乘GEMM | ~O(N) | Compute-bound (大N时) |
| ReLU激活 | 0.125 | Memory-bound |
| BatchNorm | ~1 | Memory-bound |
| Attention | ~O(d) | 取决于序列长度 |
6.3.2 Nsight Compute核心指标¶
Nsight Compute(ncu)是Kernel级性能分析工具,核心指标:
| 指标 | 含义 | 关注点 |
|---|---|---|
| Occupancy | SM上活跃Warp数 / 最大Warp数 | >50%为佳,低则检查寄存器/shared mem使用 |
| Memory Throughput | 实际带宽 / 峰值带宽 | Memory-bound kernel应>60% |
| Compute Throughput | 实际FLOPS / 峰值FLOPS | Compute-bound kernel应>60% |
| Warp Stall Reasons | Warp等待原因分布 | 找出主要stall原因 |
| L1/L2 Hit Rate | 缓存命中率 | 低命中率→内存访问模式需优化 |
# Nsight Compute分析命令
ncu --set full -o profile_result ./my_kernel
ncu --metrics sm__throughput.avg.pct_of_peak_sustained_elapsed \
--metrics dram__throughput.avg.pct_of_peak_sustained_elapsed \
./my_kernel
6.3.3 Nsight Systems时间线分析¶
Nsight Systems(nsys)是系统级时间线工具,展示CPU/GPU活动、Stream并发、API调用耗时。
# 采集时间线
nsys profile --stats=true -o timeline_report ./my_app
# 在GUI中打开查看
nsys-ui timeline_report.nsys-rep
时间线中需要关注的问题: - GPU空闲(idle)间隙 → Kernel Launch延迟 / CPU端瓶颈 - Stream之间缺乏重叠 → 重新设计Pipeline - 频繁的cudaDeviceSynchronize → 消除不必要的同步
6.3.4 常见瓶颈与优化¶
Bank Conflict(共享内存Bank冲突)¶
共享内存被划分为32个Bank(每Bank 4字节宽度)。当同一Warp的多个线程访问同一Bank的不同地址时,产生Bank Conflict,访问被串行化。
Bank 0 Bank 1 Bank 2 ... Bank 31
| | | |
Thread0 Thread1 Thread2 ... Thread31
✓ 无冲突(每线程访问不同Bank)
Thread0 → Bank 0
Thread1 → Bank 0 ← 2-way bank conflict!
解决方案:在shared memory中添加padding。
Low Occupancy¶
Occupancy低意味着SM上活跃Warp少,无法有效隐藏延迟。
常见原因: - 每线程寄存器使用过多(大于64-128个) - 共享内存使用过多 - Block size选择不当
查看方法:ncu --metrics sm__warps_active.avg.per_cycle_active
Warp Divergence¶
同一Warp内线程走不同的if/else分支,导致串行执行两个分支。
6.3.5 代码:Naive vs 优化Kernel对比¶
// file: bank_conflict_demo.cu
// 演示Bank Conflict影响与padding优化
// 编译: nvcc -arch=sm_70 -o bank_conflict bank_conflict_demo.cu
#include <cuda_runtime.h>
#include <cstdio>
constexpr int TILE_DIM = 32;
constexpr int BLOCK_ROWS = 8;
// ---- Naive转置(有Bank Conflict)----
__global__ void transpose_naive(float *odata, const float *idata,
int width, int height) {
// shared memory: 32×32, 列访问时32线程全部命中同一Bank
__shared__ float tile[TILE_DIM][TILE_DIM];
int xIndex = blockIdx.x * TILE_DIM + threadIdx.x;
int yIndex = blockIdx.y * TILE_DIM + threadIdx.y;
int index_in = xIndex + yIndex * width;
// 加载:行访问,无Bank Conflict
for (int j = 0; j < TILE_DIM; j += BLOCK_ROWS)
tile[threadIdx.y + j][threadIdx.x] = idata[index_in + j * width];
__syncthreads();
// 写出转置位置
xIndex = blockIdx.y * TILE_DIM + threadIdx.x;
yIndex = blockIdx.x * TILE_DIM + threadIdx.y;
// 列访问shared memory → 32-way Bank Conflict!
for (int j = 0; j < TILE_DIM; j += BLOCK_ROWS)
odata[xIndex + (yIndex + j) * height] =
tile[threadIdx.x][threadIdx.y + j];
}
// ---- 优化转置(Padding消除Bank Conflict)----
__global__ void transpose_optimized(float *odata, const float *idata,
int width, int height) {
// +1 padding:错开Bank映射
__shared__ float tile[TILE_DIM][TILE_DIM + 1]; // 关键优化!
int xIndex = blockIdx.x * TILE_DIM + threadIdx.x;
int yIndex = blockIdx.y * TILE_DIM + threadIdx.y;
int index_in = xIndex + yIndex * width;
for (int j = 0; j < TILE_DIM; j += BLOCK_ROWS)
tile[threadIdx.y + j][threadIdx.x] = idata[index_in + j * width];
__syncthreads();
xIndex = blockIdx.y * TILE_DIM + threadIdx.x;
yIndex = blockIdx.x * TILE_DIM + threadIdx.y;
// 列访问时由于+1 padding,相邻线程访问不同Bank → 无冲突!
for (int j = 0; j < TILE_DIM; j += BLOCK_ROWS)
odata[xIndex + (yIndex + j) * height] =
tile[threadIdx.x][threadIdx.y + j];
}
int main() {
const int WIDTH = 4096, HEIGHT = 4096;
size_t bytes = WIDTH * HEIGHT * sizeof(float);
float *h_input = (float *)malloc(bytes);
float *h_output = (float *)malloc(bytes);
for (int i = 0; i < WIDTH * HEIGHT; ++i) h_input[i] = (float)i;
float *d_input, *d_output;
cudaMalloc(&d_input, bytes);
cudaMalloc(&d_output, bytes);
cudaMemcpy(d_input, h_input, bytes, cudaMemcpyHostToDevice);
dim3 grid(WIDTH / TILE_DIM, HEIGHT / TILE_DIM);
dim3 block(TILE_DIM, BLOCK_ROWS);
// 计时Naive版
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// Warmup
transpose_naive<<<grid, block>>>(d_output, d_input, WIDTH, HEIGHT);
cudaDeviceSynchronize();
cudaEventRecord(start);
for (int i = 0; i < 100; ++i)
transpose_naive<<<grid, block>>>(d_output, d_input, WIDTH, HEIGHT);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float naive_ms;
cudaEventElapsedTime(&naive_ms, start, stop);
// 计时优化版
cudaEventRecord(start);
for (int i = 0; i < 100; ++i)
transpose_optimized<<<grid, block>>>(d_output, d_input, WIDTH, HEIGHT);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float opt_ms;
cudaEventElapsedTime(&opt_ms, start, stop);
printf("Naive transpose: %.3f ms (avg over 100 runs)\n", naive_ms / 100);
printf("Optimized transpose: %.3f ms (avg over 100 runs)\n", opt_ms / 100);
printf("Speedup: %.2fx\n", naive_ms / opt_ms);
// 带宽计算(读+写各一次)
float bw_naive = 2.0f * bytes / (naive_ms / 100.0f * 1e-3f) / 1e9f;
float bw_opt = 2.0f * bytes / (opt_ms / 100.0f * 1e-3f) / 1e9f;
printf("Effective bandwidth: naive=%.1f GB/s, optimized=%.1f GB/s\n",
bw_naive, bw_opt);
cudaFree(d_input); cudaFree(d_output);
free(h_input); free(h_output);
cudaEventDestroy(start); cudaEventDestroy(stop);
return 0;
}
编译与运行:
6.3.6 面试考点¶
Q1: 如何判断一个Kernel是compute-bound还是memory-bound? A: 计算Operational Intensity(OI = FLOPs / Bytes)。与GPU的ridge point(峰值FLOPS / 峰值带宽)比较。OI < ridge point → memory-bound。也可直接用Nsight Compute看compute throughput和memory throughput哪个更接近峰值。
Q2: 什么是Bank Conflict?如何解决? A: 共享内存有32个Bank,当Warp中多个线程访问同一Bank的不同地址时,访问被串行化。解决方案:① 在shared memory声明时添加padding(如
[32][33]代替[32][32]);② 调整访问模式使相邻线程访问相邻Bank。Q3: Occupancy越高性能越好吗? A: 不一定。Occupancy帮助隐藏延迟,但过高的Occupancy可能导致每线程可用寄存器减少,增加register spill到local memory,反而降低性能。通常50%以上就足够,关键是找到Occupancy和资源使用的平衡点。
6.4 高性能GEMM优化之旅¶
GEMM(General Matrix Multiply)是深度学习的核心算子。本节通过5步渐进优化,从Naive到接近cuBLAS水平,每步附完整代码和性能分析。
矩阵乘法定义:\(C_{M \times N} = A_{M \times K} \times B_{K \times N}\)
6.4.1 Step 1: Naive GEMM(基准)¶
// file: gemm_optimization.cu
// 5步GEMM优化全代码
// 编译: nvcc -arch=sm_70 -O3 -o gemm_opt gemm_optimization.cu -lcublas
#include <cuda_runtime.h>
#include <cublas_v2.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
// ================ Step 1: Naive GEMM ================
// 每个线程计算C的一个元素,直接从全局内存读A和B
// 性能瓶颈:全局内存访问被大量重复(A的每行被读N次,B的每列被读M次)
__global__ void gemm_naive(const float *A, const float *B, float *C,
int M, int N, int K) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < M && col < N) {
float sum = 0.0f;
for (int k = 0; k < K; ++k) {
sum += A[row * K + k] * B[k * N + col];
}
C[row * N + col] = sum;
}
}
// 性能:~300 GFLOPS (A100),约峰值的1.5%
分析: - 计算量:\(2MNK\) FLOPs - 全局内存访问量:每个线程读A的一行(K个float)和B的一列(K个float)= \(2K \times 4\) Bytes - A的每一行被N个线程重复读取 → 巨大的全局内存带宽浪费
6.4.2 Step 2: Shared Memory Tiling¶
// ================ Step 2: Shared Memory Tiling ================
// 将矩阵分块(Tile),用shared memory缓存,减少全局内存访问
constexpr int TILE_SIZE = 32;
__global__ void gemm_shared_tiling(const float *A, const float *B, float *C,
int M, int N, int K) {
__shared__ float As[TILE_SIZE][TILE_SIZE];
__shared__ float Bs[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
float sum = 0.0f;
// 沿K维度分tile迭代
for (int t = 0; t < (K + TILE_SIZE - 1) / TILE_SIZE; ++t) {
// 协作加载A的tile到shared memory
int a_col = t * TILE_SIZE + threadIdx.x;
if (row < M && a_col < K)
As[threadIdx.y][threadIdx.x] = A[row * K + a_col];
else
As[threadIdx.y][threadIdx.x] = 0.0f;
// 协作加载B的tile到shared memory
int b_row = t * TILE_SIZE + threadIdx.y;
if (b_row < K && col < N)
Bs[threadIdx.y][threadIdx.x] = B[b_row * N + col];
else
Bs[threadIdx.y][threadIdx.x] = 0.0f;
__syncthreads();
// 在shared memory中完成tile内的乘加
for (int k = 0; k < TILE_SIZE; ++k)
sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
__syncthreads();
}
if (row < M && col < N)
C[row * N + col] = sum;
}
// 性能:~1500 GFLOPS(A100),约5x提速
为什么Tiling有效?
- 全局内存访问量从 \(O(MNK)\) 降为 \(O(MNK / T)\),其中T是tile大小
- Shared memory带宽约19 TB/s(A100),全局内存仅2 TB/s → 数据复用在快速存储中完成
- 每个数据从全局内存只加载一次到shared memory,被tile内所有线程共享
6.4.3 Step 3: 共享内存分块加载 + 循环展开¶
// ================ Step 3: 共享内存分块加载 + 循环展开 ================
// 在 Step 2 基础上增加 #pragma unroll 循环展开,提高指令级并行度
constexpr int TILE3 = 32;
constexpr int FLOAT4_COUNT = TILE3 / 4; // 每行8个float4
__global__ void gemm_vectorized(const float *A, const float *B, float *C,
int M, int N, int K) {
__shared__ float As[TILE3][TILE3];
__shared__ float Bs[TILE3][TILE3];
int row = blockIdx.y * TILE3 + threadIdx.y;
int col = blockIdx.x * TILE3 + threadIdx.x;
float sum = 0.0f;
for (int t = 0; t < (K + TILE3 - 1) / TILE3; ++t) {
int a_col = t * TILE3 + threadIdx.x;
if (row < M && a_col < K)
As[threadIdx.y][threadIdx.x] = A[row * K + a_col];
else
As[threadIdx.y][threadIdx.x] = 0.0f;
// B的列方向是连续的 → 可以用float4加载
int b_row = t * TILE3 + threadIdx.y;
if (b_row < K && col < N)
Bs[threadIdx.y][threadIdx.x] = B[b_row * N + col];
else
Bs[threadIdx.y][threadIdx.x] = 0.0f;
__syncthreads();
// 内层循环展开
#pragma unroll
for (int k = 0; k < TILE3; ++k)
sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
__syncthreads();
}
if (row < M && col < N)
C[row * N + col] = sum;
}
// 性能:~2000 GFLOPS,进一步提升内存带宽利用率
循环展开核心思想: - #pragma unroll 指示编译器完全展开内层循环,消除循环控制开销 - 展开后编译器可更好地调度指令,隐藏访存延迟 - 如需真正的向量化加载(float4),需将加载语句改为 reinterpret_cast<float4*> 方式读取,一条指令搬运128位数据
6.4.4 Step 4: 寄存器Tiling(Register Blocking)¶
// ================ Step 4: Register Tiling ================
// 每个线程计算多个C元素(TM×TN的子矩阵),增加计算/访存比
constexpr int BM = 128; // Block tile M
constexpr int BN = 128; // Block tile N
constexpr int BK = 8; // Block tile K
constexpr int TM = 8; // Thread tile M(每线程计算8行)
constexpr int TN = 8; // Thread tile N(每线程计算8列)
__global__ void gemm_register_tiling(const float *A, const float *B, float *C,
int M, int N, int K) {
// Block内线程数 = (BM/TM) × (BN/TN) = 16 × 16 = 256
const int thread_row = threadIdx.x / (BN / TN); // 0-15
const int thread_col = threadIdx.x % (BN / TN); // 0-15
// Shared memory分配
__shared__ float As[BM][BK]; // 128×8
__shared__ float Bs[BK][BN]; // 8×128
// 指向当前Block负责的C子矩阵
A += blockIdx.y * BM * K;
B += blockIdx.x * BN;
C += blockIdx.y * BM * N + blockIdx.x * BN;
// 寄存器中存储TM×TN的局部结果
float thread_results[TM][TN] = {0.0f};
// 寄存器缓存A和B的列/行片段
float reg_A[TM];
float reg_B[TN];
// 沿K维度分BK大小的tile迭代
for (int bk = 0; bk < K; bk += BK) {
// ---- 协作加载A的tile到shared memory ----
// 每线程加载多个元素以填满 BM×BK 的shared memory
for (int load_offset = 0; load_offset < BM * BK;
load_offset += blockDim.x) {
int idx = load_offset + threadIdx.x;
if (idx < BM * BK) {
int sm_row = idx / BK;
int sm_col = idx % BK;
int global_row = blockIdx.y * BM + sm_row;
int global_col = bk + sm_col;
if (global_row < M && global_col < K)
As[sm_row][sm_col] = A[sm_row * K + bk + sm_col];
else
As[sm_row][sm_col] = 0.0f;
}
}
// ---- 协作加载B的tile到shared memory ----
for (int load_offset = 0; load_offset < BK * BN;
load_offset += blockDim.x) {
int idx = load_offset + threadIdx.x;
if (idx < BK * BN) {
int sm_row = idx / BN;
int sm_col = idx % BN;
int global_row = bk + sm_row;
int global_col = blockIdx.x * BN + sm_col;
if (global_row < K && global_col < N)
Bs[sm_row][sm_col] = B[(bk + sm_row) * N +
blockIdx.x * BN + sm_col];
else
Bs[sm_row][sm_col] = 0.0f;
}
}
__syncthreads();
// ---- 寄存器级计算 ----
for (int dot_idx = 0; dot_idx < BK; ++dot_idx) {
// 加载A的TM个元素到寄存器
for (int i = 0; i < TM; ++i)
reg_A[i] = As[thread_row * TM + i][dot_idx];
// 加载B的TN个元素到寄存器
for (int j = 0; j < TN; ++j)
reg_B[j] = Bs[dot_idx][thread_col * TN + j];
// 外积:TM × TN次FMA
for (int i = 0; i < TM; ++i)
for (int j = 0; j < TN; ++j)
thread_results[i][j] += reg_A[i] * reg_B[j];
}
__syncthreads();
}
// ---- 写回C ----
for (int i = 0; i < TM; ++i)
for (int j = 0; j < TN; ++j) {
int global_row = blockIdx.y * BM + thread_row * TM + i;
int global_col = blockIdx.x * BN + thread_col * TN + j;
if (global_row < M && global_col < N)
C[(thread_row * TM + i) * N +
thread_col * TN + j] = thread_results[i][j];
}
}
// 性能:~6000-8000 GFLOPS(A100),计算/访存比从1提升到TM*TN=64
为什么Register Tiling有效?
| 度量 | Step 2 (Shared Tiling) | Step 4 (Register Tiling) |
|---|---|---|
| 每线程计算C元素数 | 1 | TM×TN = 64 |
| 计算/Shared访存比 | 1 FMA / 2 loads | TM×TN FMA / (TM+TN) loads |
| 线程总数 | 多但低效 | 少但高效 |
6.4.5 Step 5: Double Buffering预取¶
// ================ Step 5: Double Buffering ================
// 使用双缓冲:当前tile在计算时,下一个tile已经在加载
// 隐藏shared memory加载延迟
constexpr int DB_BM = 64;
constexpr int DB_BN = 64;
constexpr int DB_BK = 8;
constexpr int DB_TM = 4;
constexpr int DB_TN = 4;
__global__ void gemm_double_buffer(const float *A, const float *B, float *C,
int M, int N, int K) {
// 双缓冲:两组shared memory交替使用
__shared__ float As[2][DB_BM][DB_BK];
__shared__ float Bs[2][DB_BK][DB_BN];
const int thread_row = threadIdx.x / (DB_BN / DB_TN);
const int thread_col = threadIdx.x % (DB_BN / DB_TN);
float thread_results[DB_TM][DB_TN] = {0.0f};
float reg_A[DB_TM], reg_B[DB_TN];
int num_tiles = (K + DB_BK - 1) / DB_BK;
// ---- 预加载第一个tile到buffer 0 ----
for (int load_offset = 0; load_offset < DB_BM * DB_BK;
load_offset += blockDim.x) {
int idx = load_offset + threadIdx.x;
if (idx < DB_BM * DB_BK) {
int sr = idx / DB_BK, sc = idx % DB_BK;
int gr = blockIdx.y * DB_BM + sr;
int gc = sc;
As[0][sr][sc] = (gr < M && gc < K) ? A[gr * K + gc] : 0.0f;
}
}
for (int load_offset = 0; load_offset < DB_BK * DB_BN;
load_offset += blockDim.x) {
int idx = load_offset + threadIdx.x;
if (idx < DB_BK * DB_BN) {
int sr = idx / DB_BN, sc = idx % DB_BN;
int gr = sr;
int gc = blockIdx.x * DB_BN + sc;
Bs[0][sr][sc] = (gr < K && gc < N) ? B[gr * N + gc] : 0.0f;
}
}
__syncthreads();
// ---- 主循环:计算当前buffer,预取下一个buffer ----
for (int tile = 0; tile < num_tiles; ++tile) {
int cur_buf = tile % 2;
int nxt_buf = 1 - cur_buf;
// 异步预加载下一个tile到nxt_buf
if (tile + 1 < num_tiles) {
int next_bk = (tile + 1) * DB_BK;
for (int load_offset = 0; load_offset < DB_BM * DB_BK;
load_offset += blockDim.x) {
int idx = load_offset + threadIdx.x;
if (idx < DB_BM * DB_BK) {
int sr = idx / DB_BK, sc = idx % DB_BK;
int gr = blockIdx.y * DB_BM + sr;
int gc = next_bk + sc;
As[nxt_buf][sr][sc] = (gr < M && gc < K) ?
A[gr * K + gc] : 0.0f;
}
}
for (int load_offset = 0; load_offset < DB_BK * DB_BN;
load_offset += blockDim.x) {
int idx = load_offset + threadIdx.x;
if (idx < DB_BK * DB_BN) {
int sr = idx / DB_BN, sc = idx % DB_BN;
int gr = next_bk + sr;
int gc = blockIdx.x * DB_BN + sc;
Bs[nxt_buf][sr][sc] = (gr < K && gc < N) ?
B[gr * N + gc] : 0.0f;
}
}
}
// 计算当前buffer
for (int dot_idx = 0; dot_idx < DB_BK; ++dot_idx) {
for (int i = 0; i < DB_TM; ++i)
reg_A[i] = As[cur_buf][thread_row * DB_TM + i][dot_idx];
for (int j = 0; j < DB_TN; ++j)
reg_B[j] = Bs[cur_buf][dot_idx][thread_col * DB_TN + j];
for (int i = 0; i < DB_TM; ++i)
for (int j = 0; j < DB_TN; ++j)
thread_results[i][j] += reg_A[i] * reg_B[j];
}
__syncthreads(); // 确保预加载完成
}
// 写回C
for (int i = 0; i < DB_TM; ++i)
for (int j = 0; j < DB_TN; ++j) {
int gr = blockIdx.y * DB_BM + thread_row * DB_TM + i;
int gc = blockIdx.x * DB_BN + thread_col * DB_TN + j;
if (gr < M && gc < N)
C[gr * N + gc] = thread_results[i][j];
}
}
6.4.6 完整测试框架¶
// (接上面的kernel定义,下面是main函数和验证代码)
void init_matrix(float *mat, int rows, int cols) {
for (int i = 0; i < rows * cols; ++i)
mat[i] = (float)(rand() % 10) * 0.1f;
}
void cpu_gemm(const float *A, const float *B, float *C,
int M, int N, int K) {
for (int i = 0; i < M; ++i)
for (int j = 0; j < N; ++j) {
float sum = 0.0f;
for (int k = 0; k < K; ++k)
sum += A[i * K + k] * B[k * N + j];
C[i * N + j] = sum;
}
}
float benchmark_kernel(void (*launch_fn)(const float*, const float*, float*,
float*, int, int, int),
const float *d_A, const float *d_B, float *d_C,
float *d_ref, int M, int N, int K, const char *name) {
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// Warmup
launch_fn(d_A, d_B, d_C, d_ref, M, N, K);
cudaDeviceSynchronize();
// Benchmark
int iters = 20;
cudaEventRecord(start);
for (int i = 0; i < iters; ++i)
launch_fn(d_A, d_B, d_C, d_ref, M, N, K);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms;
cudaEventElapsedTime(&ms, start, stop);
ms /= iters;
float gflops = 2.0f * M * N * K / (ms * 1e-3f) / 1e9f;
printf("%-25s: %8.3f ms | %8.1f GFLOPS\n", name, ms, gflops);
cudaEventDestroy(start);
cudaEventDestroy(stop);
return gflops;
}
// 各Kernel的launch wrapper
void launch_naive(const float *A, const float *B, float *C,
float *ref, int M, int N, int K) {
dim3 block(32, 32);
dim3 grid((N + 31) / 32, (M + 31) / 32);
gemm_naive<<<grid, block>>>(A, B, C, M, N, K);
}
void launch_shared(const float *A, const float *B, float *C,
float *ref, int M, int N, int K) {
dim3 block(TILE_SIZE, TILE_SIZE);
dim3 grid((N + TILE_SIZE - 1) / TILE_SIZE, (M + TILE_SIZE - 1) / TILE_SIZE);
gemm_shared_tiling<<<grid, block>>>(A, B, C, M, N, K);
}
void launch_vectorized(const float *A, const float *B, float *C,
float *ref, int M, int N, int K) {
dim3 block(TILE3, TILE3);
dim3 grid((N + TILE3 - 1) / TILE3, (M + TILE3 - 1) / TILE3);
gemm_vectorized<<<grid, block>>>(A, B, C, M, N, K);
}
void launch_register_tiling(const float *A, const float *B, float *C,
float *ref, int M, int N, int K) {
dim3 block((BM / TM) * (BN / TN)); // 256 threads
dim3 grid((N + BN - 1) / BN, (M + BM - 1) / BM);
gemm_register_tiling<<<grid, block>>>(A, B, C, M, N, K);
}
void launch_double_buffer(const float *A, const float *B, float *C,
float *ref, int M, int N, int K) {
dim3 block((DB_BM / DB_TM) * (DB_BN / DB_TN)); // 256 threads
dim3 grid((N + DB_BN - 1) / DB_BN, (M + DB_BM - 1) / DB_BM);
gemm_double_buffer<<<grid, block>>>(A, B, C, M, N, K);
}
int main() {
const int M = 2048, N = 2048, K = 2048;
size_t size_A = M * K * sizeof(float);
size_t size_B = K * N * sizeof(float);
size_t size_C = M * N * sizeof(float);
float *h_A = (float *)malloc(size_A);
float *h_B = (float *)malloc(size_B);
float *h_C = (float *)malloc(size_C);
srand(42);
init_matrix(h_A, M, K);
init_matrix(h_B, K, N);
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, size_A);
cudaMalloc(&d_B, size_B);
cudaMalloc(&d_C, size_C);
cudaMemcpy(d_A, h_A, size_A, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size_B, cudaMemcpyHostToDevice);
printf("GEMM Optimization Benchmark (M=%d, N=%d, K=%d)\n", M, N, K);
printf("=============================================\n");
benchmark_kernel(launch_naive, d_A, d_B, d_C, nullptr, M, N, K,
"Step1: Naive");
benchmark_kernel(launch_shared, d_A, d_B, d_C, nullptr, M, N, K,
"Step2: Shared Tiling");
benchmark_kernel(launch_vectorized, d_A, d_B, d_C, nullptr, M, N, K,
"Step3: Vectorized");
benchmark_kernel(launch_register_tiling, d_A, d_B, d_C, nullptr, M, N, K,
"Step4: Register Tiling");
benchmark_kernel(launch_double_buffer, d_A, d_B, d_C, nullptr, M, N, K,
"Step5: Double Buffer");
// cuBLAS作为参考
cublasHandle_t handle;
cublasCreate(&handle);
float alpha = 1.0f, beta = 0.0f;
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// Warmup
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, N, M, K,
&alpha, d_B, N, d_A, K, &beta, d_C, N);
cudaDeviceSynchronize();
cudaEventRecord(start);
for (int i = 0; i < 20; ++i)
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, N, M, K,
&alpha, d_B, N, d_A, K, &beta, d_C, N);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float cublas_ms;
cudaEventElapsedTime(&cublas_ms, start, stop);
cublas_ms /= 20;
float cublas_gflops = 2.0f * M * N * K / (cublas_ms * 1e-3f) / 1e9f;
printf("%-25s: %8.3f ms | %8.1f GFLOPS\n", "cuBLAS (reference)", cublas_ms,
cublas_gflops);
cublasDestroy(handle);
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
free(h_A); free(h_B); free(h_C);
cudaEventDestroy(start); cudaEventDestroy(stop);
return 0;
}
编译与运行:
预期性能对比(A100 80GB为例,FP32 SGEMM):
| Step | 方法 | 典型GFLOPS | 相对Naive |
|---|---|---|---|
| 1 | Naive | ~300 | 1x |
| 2 | Shared Memory Tiling | ~1500 | 5x |
| 3 | Vectorized Load | ~2000 | 6.7x |
| 4 | Register Tiling | ~7000 | 23x |
| 5 | Double Buffering | ~8000 | 27x |
| Ref | cuBLAS | ~15000 | 50x |
cuBLAS还使用了更激进的autotuning、汇编级PTX指令、warp-level MMA等优化。
6.4.7 CUTLASS库简介¶
CUTLASS(CUDA Templates for Linear Algebra Subroutines)是NVIDIA开源的高性能GEMM模板库:
CUTLASS架构层次:
├── Epilogue(输出处理:ReLU fuse、bias add)
├── Mainloop(主循环:double buffering、async copy)
├── Warp-level MMA(Tensor Core WMMA/MMA指令)
├── Thread-level Fragment(寄存器tile)
└── Memory TileIterator(全局→共享→寄存器的数据搬运)
核心优势: - 模板化设计:编译期确定tile大小、数据类型、布局 - 接近cuBLAS性能(90-99%) - 可自定义epilogue(如GEMM + bias + ReLU融合) - 支持Tensor Core各精度(FP16/BF16/TF32/INT8/FP8)
6.4.8 面试考点¶
Q1: 为什么Tiling有效?Tile大小如何选择? A: Tiling利用数据局部性(data locality),将频繁访问的数据放入高速的shared memory中复用,减少慢速全局内存的访问次数。Tile大小选择需平衡:① 太大→shared memory不够 / Occupancy低;② 太小→计算/访存比低、全局内存事务效率低。典型选择:Block tile 64-256,K tile 4-16,Thread tile 4-8。需要根据目标GPU的shared memory容量和寄存器数量调优。
Q2: Register Tiling的核心思想是什么? A: 让每个线程负责C矩阵的一个TM×TN子块而非单个元素。这样在内层K循环中,每次从shared memory加载TM+TN个数据,但执行TM×TN次FMA,计算/访存比从2:1提升到约TM×TN/(TM+TN),极大提高了计算密度。
Q3: Double Buffering解决了什么问题? A: 解决了shared memory加载与计算之间的串行依赖。使用两组shared memory buffer,当Kernel在buffer A中计算时,异步将下一个tile加载到buffer B,实现了数据预取(prefetching)与计算的重叠,消除了数据加载的等待时间。
6.5 Warp级原语¶
6.5.1 Warp Shuffle指令¶
Warp Shuffle允许Warp内线程直接交换寄存器数据,无需经过共享内存,延迟极低(1-2周期)。
__shfl_sync(mask, val, srcLane) // 从srcLane广播
__shfl_up_sync(mask, val, delta) // 从lane-delta获取
__shfl_down_sync(mask, val, delta) // 从lane+delta获取
__shfl_xor_sync(mask, val, laneMask) // 从lane^laneMask获取
参数说明: - mask: 参与线程掩码,通常0xFFFFFFFF(全部32线程) - val: 当前线程贡献的值 - srcLane / delta / laneMask: 数据来源的指定方式
6.5.2 Warp级归约代码¶
// file: warp_reduce.cu
// 使用Warp Shuffle实现高效归约
// 编译: nvcc -arch=sm_70 -o warp_reduce warp_reduce.cu
#include <cuda_runtime.h>
#include <cstdio>
constexpr int WARP_SIZE = 32;
// ---- Warp级求和归约(无共享内存)----
__device__ float warp_reduce_sum(float val) {
// 5步蝶形归约(log2(32) = 5)
for (int offset = WARP_SIZE / 2; offset > 0; offset >>= 1)
val += __shfl_down_sync(0xFFFFFFFF, val, offset);
return val; // 结果在lane 0
}
// ---- Warp级最大值归约 ----
__device__ float warp_reduce_max(float val) {
for (int offset = WARP_SIZE / 2; offset > 0; offset >>= 1)
val = fmaxf(val, __shfl_down_sync(0xFFFFFFFF, val, offset));
return val;
}
// ---- Block级归约:先Warp内归约,再跨Warp归约 ----
__global__ void block_reduce_kernel(const float *input, float *output, int n) {
__shared__ float warp_sums[32]; // 最多32个Warp
int tid = threadIdx.x;
int global_id = blockIdx.x * blockDim.x + threadIdx.x;
// 每线程加载一个元素
float val = (global_id < n) ? input[global_id] : 0.0f;
// 第一步:Warp内归约
int lane = tid % WARP_SIZE;
int warp_id = tid / WARP_SIZE;
val = warp_reduce_sum(val);
// Warp内lane 0将结果写入shared memory
if (lane == 0)
warp_sums[warp_id] = val;
__syncthreads();
// 第二步:第一个Warp读取所有Warp的结果,再做一次归约
int num_warps = (blockDim.x + WARP_SIZE - 1) / WARP_SIZE;
if (warp_id == 0) {
val = (lane < num_warps) ? warp_sums[lane] : 0.0f;
val = warp_reduce_sum(val);
}
// Block的结果在thread 0
if (tid == 0)
output[blockIdx.x] = val;
}
// ---- 验证用CPU归约 ----
float cpu_reduce_sum(const float *data, int n) {
double sum = 0;
for (int i = 0; i < n; ++i) sum += data[i];
return (float)sum;
}
int main() {
const int N = 1 << 20; // 1M elements
size_t bytes = N * sizeof(float);
float *h_input = (float *)malloc(bytes); // 动态内存分配,需手动free释放
for (int i = 0; i < N; ++i) h_input[i] = 1.0f; // 全1,结果应为N
float *d_input, *d_partial;
cudaMalloc(&d_input, bytes);
cudaMemcpy(d_input, h_input, bytes, cudaMemcpyHostToDevice);
int threads = 256;
int blocks = (N + threads - 1) / threads;
cudaMalloc(&d_partial, blocks * sizeof(float));
// 第一轮归约
block_reduce_kernel<<<blocks, threads>>>(d_input, d_partial, N);
// 第二轮归约(把partial results再归约一次)
float *d_result;
cudaMalloc(&d_result, sizeof(float));
block_reduce_kernel<<<1, ((blocks + 31) / 32) * 32>>>(d_partial, d_result,
blocks);
float gpu_result;
cudaMemcpy(&gpu_result, d_result, sizeof(float), cudaMemcpyDeviceToHost);
float cpu_result = cpu_reduce_sum(h_input, N);
printf("GPU reduce sum: %.0f\n", gpu_result);
printf("CPU reduce sum: %.0f\n", cpu_result);
printf("Match: %s\n", (fabsf(gpu_result - cpu_result) < 1.0f) ?
"PASSED" : "FAILED");
cudaFree(d_input); cudaFree(d_partial); cudaFree(d_result);
free(h_input);
return 0;
}
编译与运行:
6.5.3 Warp Shuffle应用场景¶
| 应用 | 原语 | 说明 |
|---|---|---|
| 归约(sum/max/min) | __shfl_down_sync | 替代shared memory归约,无bank conflict |
| 广播 | __shfl_sync | 一个lane的值广播给整个Warp |
| 扫描(prefix sum) | __shfl_up_sync | Warp级exclusive/inclusive scan |
| 矩阵转置 | __shfl_xor_sync | Warp内数据重排 |
| Softmax | __shfl_down_sync + __shfl_sync | 行级max和sum的Warp归约 |
6.5.4 Cooperative Groups简介¶
Cooperative Groups是CUDA 9引入的灵活线程分组API,统一了多种粒度的同步和协作:
#include <cooperative_groups.h> // 引入头文件
namespace cg = cooperative_groups;
__global__ void kernel() {
// 当前线程所在Warp的活跃线程组
auto warp = cg::coalesced_threads();
// 当前Thread Block
auto block = cg::this_thread_block();
// 按大小分割成子组
auto tile = cg::tiled_partition<16>(block); // 16线程的tile
// 子组内归约
float val = tile.thread_rank();
for (int i = tile.size() / 2; i > 0; i >>= 1) {
val += tile.shfl_down(val, i);
}
// 子组内同步
tile.sync();
}
Cooperative Groups优势: - 比硬编码的__shfl_down_sync + __syncthreads更安全 - 支持Grid级同步(cooperative_groups::this_grid().sync()) - 分支代码中自动跟踪活跃线程
6.5.5 面试考点¶
Q1: Warp Shuffle相比Shared Memory归约有什么优势? A: ① 无需分配shared memory → 不影响Occupancy;② 无Bank Conflict → 无需padding;③ 延迟更低(1-2周期 vs shared memory的~20周期);④ 不需要
__syncthreads()同步。但局限在于只能在Warp内(32线程)通信。Q2:
__shfl_down_sync的mask参数有什么作用? A: mask是一个32位整数,每bit代表一个lane是否参与操作。设为0xFFFFFFFF表示全部32线程参与。在分支代码中,只有mask中标记的线程才会执行shuffle操作,避免未定义行为。CUDA 9.0之后所有Warp级原语都要求显式mask,以确保线程同步语义正确。
6.6 内存优化进阶¶
6.6.1 Host内存类型¶
| 类型 | API | 特点 | 适用场景 |
|---|---|---|---|
| Pageable Memory | malloc / new | 默认,可能被OS换页 | 一般CPU计算 |
| Pinned Memory | cudaMallocHost | 页锁定,DMA直接访问 | 频繁H2D/D2H传输 |
| Unified Memory | cudaMallocManaged | 自动迁移,CPU/GPU同指针 | 原型开发 |
| 零拷贝(Mapped) | cudaHostAlloc(FLAG_MAPPED) | GPU直接访问Host内存 | 小数据/稀疏访问 |
6.6.2 Pinned Memory详解¶
// Pinned Memory使用
float *h_pinned;
cudaMallocHost(&h_pinned, size); // 页锁定分配
// 异步传输(必须Pinned Memory + 非默认Stream)
cudaMemcpyAsync(d_data, h_pinned, size,
cudaMemcpyHostToDevice, stream);
// 释放
cudaFreeHost(h_pinned);
性能对比: - Pageable → PCIe传输需要经过临时Pinned buffer → 两次拷贝 - Pinned → DMA引擎直接搬运 → 一次拷贝,带宽提升约2x - 注意:Pinned Memory过多会降低系统可用内存,影响OS性能
6.6.3 Unified Memory¶
float *data; // 指针:存储变量的内存地址
cudaMallocManaged(&data, N * sizeof(float));
// CPU和GPU使用同一个指针
for (int i = 0; i < N; ++i) data[i] = i; // CPU访问
kernel<<<grid, block>>>(data, N); // GPU访问
cudaDeviceSynchronize();
printf("%f\n", data[0]); // CPU再次访问(自动迁移回来)
cudaFree(data);
Unified Memory的页迁移机制: - 采用按需页迁移(demand paging):首次访问时才迁移对应的页面 - Volta+支持硬件页错误处理(page fault),GPU可以按需从CPU内存拉取页面 - 可用cudaMemPrefetchAsync提示迁移方向,减少page fault
// 显式预取到GPU
cudaMemPrefetchAsync(data, N * sizeof(float), 0); // deviceId=0
// 预取回CPU
cudaMemPrefetchAsync(data, N * sizeof(float), cudaCpuDeviceId);
6.6.4 合并访问(Coalesced Access)¶
全局内存以128字节事务为粒度访问。当一个Warp的32个线程访问连续的128字节时,只需1个事务(完美合并);否则可能需要多个事务。
✅ 合并访问(1个事务):
Thread0→addr[0], Thread1→addr[1], ..., Thread31→addr[31]
❌ 跨步访问(32个事务):
Thread0→addr[0], Thread1→addr[32], ..., Thread31→addr[31*32]
优化建议: - 使用SoA(Structure of Arrays)而非AoS(Array of Structures) - 矩阵按行存储,Kernel中线程沿列方向排布 - 使用padding对齐到128字节边界
// ❌ AoS:非合并(相邻线程访问不连续地址)
struct Particle { float x, y, z, vx, vy, vz; }; // struct结构体:自定义复合数据类型
Particle particles[N];
// Thread i 访问 particles[i].x → 跨步24字节
// ✅ SoA:合并(相邻线程访问连续地址)
float x[N], y[N], z[N], vx[N], vy[N], vz[N];
// Thread i 访问 x[i] → 连续4字节,完美合并
6.6.5 L1/L2 Cache配置¶
L1 Cache / Shared Memory分配(Volta+):
Volta架构开始,L1 Cache和Shared Memory共享同一物理SRAM(每SM 128KB-228KB),可配置分配比例:
// 设置偏好
cudaFuncSetAttribute(myKernel,
cudaFuncAttributePreferredSharedMemoryCarveout,
cudaSharedmemCarveoutMaxShared); // 最大化shared memory
// 可选值:
// cudaSharedmemCarveoutDefault (默认平衡)
// cudaSharedmemCarveoutMaxShared (最大化shared memory)
// cudaSharedmemCarveoutMaxL1 (最大化L1 cache)
L2 Cache持久化(Ampere+):
// 设置L2 cache中为特定数据预留的窗口
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, 0);
size_t l2_persist_size = min((size_t)(prop.l2CacheSize * 0.75),
prop.persistingL2CacheMaxSize);
cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, l2_persist_size);
// 设置访问策略
cudaStreamAttrValue attr;
attr.accessPolicyWindow.base_ptr = d_data;
attr.accessPolicyWindow.num_bytes = data_size;
attr.accessPolicyWindow.hitRatio = 0.6f; // 60%的访问会使用L2
attr.accessPolicyWindow.hitProp = cudaAccessPropertyPersisting;
attr.accessPolicyWindow.missProp = cudaAccessPropertyStreaming;
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &attr);
6.6.6 面试考点¶
Q1: Pinned Memory为什么能加速H2D传输? A: 普通Pageable Memory在传输前,CUDA驱动需要先将数据拷贝到一块临时的Pinned buffer,再由DMA引擎搬运到GPU。Pinned Memory跳过了这步中间拷贝,DMA引擎直接从Host物理地址读取数据到GPU显存,减少一次拷贝,并且支持异步传输与计算重叠。
Q2: Unified Memory的缺点是什么? A: ① Page fault导致的迁移延迟不可预测,影响性能稳定性;② 频繁CPU/GPU交替访问导致页面"乒乓"(thrashing),性能急剧下降;③ 多GPU场景下迁移策略复杂;④ 无法精细控制数据布局。生产环境通常用显式的
cudaMemcpy以获得可预测的性能。Q3: 什么是合并访问?举一个非合并访问的例子及优化方法。 A: 合并访问指Warp内相邻线程访问全局内存中连续地址,使多个线程的请求合并为最少的128字节事务。典型非合并场景:矩阵列访问(跨步=矩阵宽度)。优化方法:① 先加载到shared memory再按列读取(tiling);② 转换为SoA布局;③ 转置矩阵。
6.7 面试高频题¶
题目1:CUDA中的内存层次及其带宽差异¶
Q: 请描述CUDA中的内存层次结构,并比较各层的带宽和延迟。
A:
| 内存层次 | 作用域 | 带宽(A100) | 延迟 | 大小 |
|---|---|---|---|---|
| 寄存器 | 线程私有 | ~19 TB/s | 1 cycle | 每SM 65536×32bit |
| L1 / Shared Memory | Block内共享 | ~19 TB/s | ~20 cycles | 每SM 164-228 KB |
| L2 Cache | 全GPU共享 | ~5 TB/s | ~200 cycles | 40 MB (A100) |
| Global Memory (HBM) | 全GPU共享 | ~2 TB/s | ~400 cycles | 40/80 GB (A100) |
| Host Memory (DRAM) | CPU域 | ~50 GB/s | ~1000 cycles | 数百GB |
| PCIe/NVLink | 跨CPU-GPU | 32/600 GB/s | 微秒级 | N/A |
优化原则:"数据尽量靠近计算"——优先使用寄存器 > shared memory > L2 > global memory。
题目2:解释Warp Divergence及其影响¶
Q: 什么是Warp Divergence?它如何影响性能?如何避免?
A:
定义:同一Warp内的32个线程在遇到条件分支(if/else)时,如果部分线程进入true分支、部分进入false分支,GPU必须串行执行两个分支——先执行true分支(false线程闲置),再执行false分支(true线程闲置)。
性能影响:最坏情况下,分支代码的执行时间等于所有分支执行时间之和,而非最长分支的时间。
避免方法: 1. 按Warp对齐分支条件:确保同一Warp的线程走同一分支
// ❌ Bad:相邻线程可能走不同分支
if (threadIdx.x % 2 == 0) { ... } else { ... }
// ✅ Good:以Warp为单位分支
if (threadIdx.x / 32 == 0) { ... } else { ... }
val = cond ? a : b → val = cond * a + (1-cond) * b 3. 数据预排序:让相似数据聚集在同一Warp 题目3:如何选择Block Size?¶
Q: CUDA kernel的block size如何选择?有哪些考虑因素?
A:
- 必须是32的倍数(一个Warp的大小),否则浪费线程
- 常用值:128、256、512
- Occupancy考虑:使用
cudaOccupancyMaxPotentialBlockSize()自动计算 - 寄存器压力:block越大 → 每SM上线程越多 → 每线程可用寄存器越少
- Shared Memory:block越大 → 共享内存需求可能越大
- Grid充分覆盖:确保生成足够多的Block来占满所有SM
int minGridSize, blockSize;
cudaOccupancyMaxPotentialBlockSize(&minGridSize, &blockSize,
myKernel, 0, 0);
int gridSize = (N + blockSize - 1) / blockSize;
myKernel<<<gridSize, blockSize>>>(...);
题目4:GEMM优化为什么用Tiling?¶
Q: 矩阵乘法为什么要做Tiling?从计算复杂度和内存访问两个角度分析。
A:
计算复杂度:\(O(M \times N \times K)\),不随Tiling改变。
内存访问分析:
对于M×K的矩阵A与K×N的矩阵B相乘: - Naive:每个C元素需要读A的一行和B的一列,总全局内存访问量 ≈ \(M \times N \times 2K \times 4\) Bytes - Tile大小T的Tiling:沿K维分为K/T个tile,每个Block加载T²大小的A片和B片到shared memory,被T²个线程共享。全局内存访问量降为 \(M \times N \times 2K/T \times 4\) Bytes
当T=32时,全局内存访问量减少32倍。数据在~19 TB/s的shared memory中被复用,而不是在~2 TB/s的全局内存中重复加载。
题目5:CUDA中如何实现原子操作?有什么性能问题?¶
Q: CUDA原子操作的原理和性能问题是什么?
A:
原理:原子操作(如atomicAdd、atomicCAS)保证对同一内存地址的读-改-写操作是不可分割的,不会被其他线程打断。
性能问题: - 原子操作会串行化对同一地址的并发访问 - 大量线程对少量地址做原子操作 → 严重的竞争(contention) - 全局内存原子操作延迟约400+ cycles
优化策略: 1. 分层归约:先Warp内用shuffle归约,再Block内用shared memory原子,最后才用全局原子 2. 使用shared memory原子代替全局原子(Kepler+支持shared memory atomicAdd) 3. 使用privatization:每个线程/Block维护局部计数器,最后合并
// ❌ Bad:所有线程竞争同一个全局计数器
atomicAdd(&global_counter, 1);
// ✅ Good:先Block内归约,再原子加
__shared__ int block_count;
if (threadIdx.x == 0) block_count = 0;
__syncthreads();
atomicAdd(&block_count, local_count); // shared memory atomic
__syncthreads();
if (threadIdx.x == 0)
atomicAdd(&global_counter, block_count); // 每Block只1次全局atomic
题目6:Stream和Event的区别与联系¶
Q: 请对比CUDA Stream和Event的作用。
A:
| 维度 | Stream | Event |
|---|---|---|
| 本质 | GPU操作的有序队列 | 时间线上的标记点 |
| 主要用途 | 实现并发执行、重叠传输与计算 | 计时、跨Stream同步 |
| 同步语义 | cudaStreamSynchronize 等待整个Stream | cudaEventSynchronize 等待到标记点 |
| 跨Stream同步 | 不直接支持 | cudaStreamWaitEvent(streamB, eventA) → B等A到达event |
| 计时 | 不支持 | cudaEventElapsedTime(start, stop) GPU端精确计时 |
典型的跨Stream同步模式:
// Stream A产生数据,Stream B消费
cudaEventRecord(data_ready, streamA); // 在A中记录事件
cudaStreamWaitEvent(streamB, data_ready); // B等待A的事件
kernelB<<<..., streamB>>>(...); // B中的操作等到A完成才开始
题目7:如何优化Kernel Launch Latency?¶
Q: CUDA kernel的启动延迟是多少?如何减少其影响?
A:
Kernel Launch延迟:通常5-10μs(从CPU发出到GPU开始执行)。虽然单次延迟很小,但大量小Kernel连续启动时,延迟会累积成为瓶颈。
优化策略: 1. Kernel融合(Kernel Fusion):将多个小Kernel合并为一个大Kernel 2. CUDA Graphs:将一系列Kernel + memcpy捕获为graph,一次launch全部执行 3. 增大每个Kernel的工作量:避免启动计算量极小的Kernel 4. 使用Graph API:
// CUDA Graph使用示例
cudaGraph_t graph;
cudaGraphExec_t instance;
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
kernelA<<<..., stream>>>(...);
kernelB<<<..., stream>>>(...);
kernelC<<<..., stream>>>(...);
cudaStreamEndCapture(stream, &graph);
cudaGraphInstantiate(&instance, graph, nullptr, nullptr, 0);
// 后续多次launch只需一个调用
for (int i = 0; i < 1000; ++i)
cudaGraphLaunch(instance, stream);
题目8:比较cuBLAS、CUTLASS、Triton的适用场景¶
Q: 你会在什么场景下选择cuBLAS、CUTLASS或Triton?
A:
| 维度 | cuBLAS | CUTLASS | Triton |
|---|---|---|---|
| 开发者 | NVIDIA(闭源) | NVIDIA(开源C++模板) | OpenAI(开源Python DSL) |
| 性能 | 最优(手写汇编+autotuning) | 接近cuBLAS(90-99%) | 接近cuBLAS(80-95%) |
| 灵活性 | 低(只有标准BLAS接口) | 高(可自定义epilogue、数据类型、布局) | 中高(Python级别自定义) |
| 开发难度 | 最低(一行调用) | 高(需要理解模板元编程) | 中(Python编写Kernel) |
| 典型场景 | 标准GEMM/GEMV | 自定义融合算子(GEMM+激活+残差) | 快速原型、FlashAttention实现 |
| 支持硬件 | 仅NVIDIA | 仅NVIDIA | NVIDIA + AMD (实验) |
选择建议: - 标准计算 → cuBLAS - 需要融合算子或自定义精度 → CUTLASS - 快速原型和算法研究 → Triton - 从PyTorch中调用 → torch.compile(底层自动选择Triton或cuBLAS)
总结:CUDA优化思维导图¶
CUDA高级优化
├── 计算优化
│ ├── Tensor Core (WMMA/MMA)
│ ├── Register Tiling (增大计算/访存比)
│ ├── 指令级并行 (ILP, #pragma unroll)
│ └── Warp级原语 (Shuffle归约)
│
├── 内存优化
│ ├── Shared Memory Tiling (数据复用)
│ ├── 合并访问 (Coalesced Access)
│ ├── 向量化加载 (float4/LDG.128)
│ ├── Bank Conflict消除 (Padding)
│ ├── Pinned Memory (加速传输)
│ └── L2 Cache Persistence
│
├── 并发优化
│ ├── Multi-Stream Pipeline
│ ├── CUDA Graphs (减少Launch延迟)
│ └── Double Buffering (预取)
│
├── 分析工具
│ ├── Roofline Model (瓶颈定位)
│ ├── Nsight Compute (Kernel级分析)
│ └── Nsight Systems (系统级时间线)
│
└── 高级库
├── cuBLAS (标准BLAS)
├── CUTLASS (自定义GEMM模板)
└── Triton (Python Kernel DSL)
推荐学习路径: 1. 先理解Roofline Model,学会判断Kernel瓶颈类型 2. 掌握Shared Memory Tiling和合并访问(最基础的优化) 3. 学习GEMM五步优化,理解每一步解决什么问题 4. 掌握Stream/Event异步编程 5. 深入Tensor Core和Warp Shuffle 6. 使用Nsight工具验证优化效果