CUDA高级优化实战:Stream、特殊内存与卷积优化---Week3学习总结
一、写在前面
前两周跟着课程学完基础知识和Shared Memory优化后,说实话,当时看到矩阵乘法从几百ms优化到几十ms,那种成就感真的很爽。Week 3进入了更硬核的内容,学完这周我感觉自己对GPU的理解又上了一个台阶。
这周主要搞了三个方向的东西:
首先是CUDA Stream,这玩意儿让我意识到GPU不只是能并行计算,连数据传输都能和计算同时进行。
然后是Constant Memory和Texture Memory这两种特殊内存。之前只知道Global Memory和Shared Memory,这周发现GPU还藏了不少"黑科技"------Constant Memory能把小数据广播给整个warp,Texture Memory针对2D访问有专门的硬件优化。
最后拿2D卷积做了个综合练习,从naive的版本一路优化到结合各种技术的版本,性能提升了3.6倍。这个过程让我真正理解了"性能优化不是单点突破,而是系统工程"这句话的含义。
这篇文章记录了我这周的学习过程和踩过的坑,希望对同样在学CUDA的朋友有帮助。
二、CUDA Stream:让GPU"一心多用"
2.1 Stream到底是个啥
刚开始看到Stream这个概念的时候,我是有点懵的。后来想明白了,其实就是GPU上的一个任务队列。
你可以这么理解:默认情况下,所有CUDA操作都在一条"流水线"(默认Stream 0)上排队执行,前一个任务不结束,后一个任务就得等着。这就像食堂只开了一个窗口,大家只能排队。
而创建多个Stream就像开了多个窗口,可以:
- 边传数据边计算:一个Stream在跑kernel的时候,另一个Stream可以同时往GPU传数据
- 多个kernel同时跑:只要GPU资源够,不同Stream里的kernel可以并发执行
- 更灵活的任务调度:想让哪个任务先跑就先跑哪个
2.2 代码实战:怎么用Stream
代码其实不复杂,看个例子就懂了:
cpp
// 创建Stream
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
// 在Stream中执行操作
cudaMemcpyAsync(d_data1, h_data1, size, cudaMemcpyHostToDevice, stream1);
kernel1<<<grid, block, 0, stream1>>>(d_data1);
cudaMemcpyAsync(d_data2, h_data2, size, cudaMemcpyHostToDevice, stream2);
kernel2<<<grid, block, 0, stream2>>>(d_data2);
// 同步Stream
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
// 销毁Stream
cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);
这里有几个坑要注意:
- 一定要用
cudaMemcpyAsync,不要用cudaMemcpy。后者会把整个设备都block住,就失去并发的意义了 - Host端的内存必须用
cudaMallocHost分配(叫pinned memory),普通malloc出来的内存没法真正异步传输 - Kernel启动时第四个参数指定用哪个Stream,别忘了加
2.3 流水线模式:传输和计算同时搞
这个技巧特别实用。思路就是把数据切成几块,每块走"传到GPU → 计算 → 传回CPU"这个流程。关键是不同块可以同时处于流程的不同阶段,就像工厂流水线一样。
cpp
const int nStreams = 4;
const int chunkSize = N / nStreams;
cudaStream_t streams[nStreams];
for (int i = 0; i < nStreams; i++) {
cudaStreamCreate(&streams[i]);
}
for (int i = 0; i < nStreams; i++) {
int offset = i * chunkSize;
// H2D传输
cudaMemcpyAsync(&d_data[offset], &h_data[offset],
chunkSize * sizeof(float),
cudaMemcpyHostToDevice, streams[i]);
// Kernel计算
kernel<<<grid, block, 0, streams[i]>>>(&d_data[offset], chunkSize);
// D2H传输
cudaMemcpyAsync(&h_result[offset], &d_result[offset],
chunkSize * sizeof(float),
cudaMemcpyDeviceToHost, streams[i]);
}
cudaDeviceSynchronize();
2.4 实测数据:效果怎么样
我在RTX 4060上测了下(处理256MB数据):
| 实现方式 | 执行时间 | 加速比 |
|---|---|---|
| 单Stream顺序执行 | 45.2ms | 1.0x |
| 4 Streams并发 | 28.7ms | 1.57x |
| 8 Streams并发 | 25.3ms | 1.79x |
几点经验:
- Stream不是越多越好,我试过16个Stream反而变慢了,因为调度开销太大
- 每个chunk的数据量要够大,太小的话kernel启动开销就占主导了
- 可以用
cudaDeviceSetCacheConfig调整L1 Cache配置,有时候能再榨点性能
三、特殊内存:GPU的"私房菜"
3.1 Constant Memory:专为小数据优化的缓存
之前只知道Global Memory和Shared Memory,这周发现NVIDIA还藏了个好东西------Constant Memory。这块内存只有64KB,但有三个很厉害的特性:
- 专属缓存:有自己的constant cache,速度贼快
- 广播机制:一个warp里的所有线程读同一个地址,只需要一次内存访问。特别适合卷积核这种所有线程都要用的数据
- 只读:kernel里只能读不能写,数据必须从CPU端写进去
声明与使用:
cpp
// 设备端声明(文件作用域)
__constant__ float const_kernel[KERNEL_SIZE];
// Host端初始化
float h_kernel[KERNEL_SIZE] = {/* 卷积核数据 */};
cudaMemcpyToSymbol(const_kernel, h_kernel,
KERNEL_SIZE * sizeof(float));
// Kernel中使用
__global__ void convKernel(float* output, float* input) {
// 直接访问,无需传参
float value = input[idx] * const_kernel[0];
}
什么时候用它:
- 卷积核、滤波器这种小查找表(反正也就几KB)
- 物理常数、数学系数之类的
- 所有线程都要频繁访问的相同数据
3.2 Texture Memory:图像处理的神器
Texture Memory原本是为图形渲染设计的,但用来做通用计算也很香。它有几个硬件级的优化:
- 2D/3D访问优化:专门针对空间局部性设计的缓存,比如访问图像相邻像素这种场景
- 自动插值:硬件直接支持线性插值、双线性插值,省得自己写代码算了
- 边界处理:越界访问可以自动Clamp(边缘拉伸)或Wrap(平铺/重复),不用手写一堆if判断
用法稍微复杂点(现代CUDA用Texture对象,不是老的Texture Reference):
cpp
// 1. 分配内存并填充数据
float* d_input;
cudaMalloc(&d_input, width * height * sizeof(float));
// 2. 创建资源描述符
cudaResourceDesc resDesc = {};
resDesc.resType = cudaResourceTypePitch2D;
resDesc.res.pitch2D.devPtr = d_input;
resDesc.res.pitch2D.width = width;
resDesc.res.pitch2D.height = height;
resDesc.res.pitch2D.pitchInBytes = width * sizeof(float);
resDesc.res.pitch2D.desc = cudaCreateChannelDesc<float>();
// 3. 创建纹理描述符
cudaTextureDesc texDesc = {};
texDesc.addressMode[0] = cudaAddressModeClamp;
texDesc.addressMode[1] = cudaAddressModeClamp;
texDesc.filterMode = cudaFilterModeLinear; // 线性插值
texDesc.readMode = cudaReadModeElementType;
texDesc.normalizedCoords = false;
// 4. 创建纹理对象
cudaTextureObject_t texObj;
cudaCreateTextureObject(&texObj, &resDesc, &texDesc, nullptr);
// 5. Kernel中使用
__global__ void texKernel(float* output, cudaTextureObject_t tex) {
float value = tex2D<float>(tex, x, y); // 硬件插值
}
性能对比(图像卷积,2048x2048):
| 访问方式 | 带宽利用率 | 执行时间 |
|---|---|---|
| Global Memory | 62% | 3.8ms |
| Texture Memory | 89% | 2.1ms |
3.3 使用场景对比
| 特性 | Constant Memory | Texture Memory | Shared Memory |
|---|---|---|---|
| 容量 | 64KB | 无限制(使用Global) | 48KB/SM |
| 访问模式 | 所有线程访问相同数据 | 2D/3D空间局部性 | 任意模式 |
| 缓存策略 | 广播 | 硬件优化的2D缓存 | 用户管理 |
| 典型应用 | 卷积核、系数 | 图像处理、采样 | 块内数据共享 |
四、原子操作和Warp Shuffle
4.1 原子操作:多线程抢同一块地的解决方案
写并行代码时经常遇到一个问题:好几个线程同时要改同一个内存位置的值,怎么办?直接写肯定会出问题(race condition)。这时候就要用原子操作。
原子操作保证了"读-改-写"这个过程是一气呵成的,中间不会被别的线程插队。
cpp
__global__ void histogramKernel(int* hist, int* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
int bin = data[idx];
atomicAdd(&hist[bin], 1); // 原子加法
}
}
常用原子操作:
atomicAdd/Sub/Exch/Min/MaxatomicCAS(Compare-And-Swap):实现复杂原子逻辑的基础atomicAnd/Or/Xor:位操作
关于性能的坑:
- 原子操作本身不慢,慢的是冲突。如果一堆线程都在抢同一个位置,那就得排队等,性能就掉下来了
- 冲突严重的时候,可以先在Shared Memory里做块内聚合,最后再一次性原子更新全局结果
- 新卡(Compute Capability 6.0之后)的原子操作比老卡快多了,所以别被网上老文章吓到
一个实际优化案例:
cpp
// 优化前:直接原子操作
__global__ void histNaive(int* hist, int* data) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
atomicAdd(&hist[data[idx]], 1); // 全局原子冲突高
}
// 优化后:两级聚合
__global__ void histOptimized(int* hist, int* data) {
__shared__ int s_hist[NUM_BINS];
// 块内初始化
if (threadIdx.x < NUM_BINS) s_hist[threadIdx.x] = 0;
__syncthreads();
// 块内原子累加
int idx = threadIdx.x + blockIdx.x * blockDim.x;
atomicAdd(&s_hist[data[idx]], 1);
__syncthreads();
// 块间原子更新
if (threadIdx.x < NUM_BINS) {
atomicAdd(&hist[threadIdx.x], s_hist[threadIdx.x]);
}
}
性能提升:3.2x(实测数据,1M元素,256 bins)
4.2 Warp Shuffle:warp内部的"传纸条"
这个功能我觉得设计得很巧妙。同一个warp里的线程可以直接交换寄存器数据,不用经过Shared Memory。
想象一下,32个人站成一排,每个人手里有个数字。用Shuffle指令,第1个人可以直接看到第17个人手里的数,不需要把数字写到黑板上(Shared Memory)再读回来。
cpp
// Warp内规约求和
__inline__ __device__ float warpReduce(float val) {
for (int offset = 16; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xffffffff, val, offset);
}
return val;
}
// 完整的块规约
__global__ void reduceKernel(float* g_out, float* g_in, int n) {
__shared__ float s_data[32]; // 每warp一个元素
int tid = threadIdx.x;
int idx = blockIdx.x * blockDim.x + tid;
float sum = (idx < n) ? g_in[idx] : 0.0f;
// Warp内规约
sum = warpReduce(sum);
// 每warp的首线程写入Shared Memory
if (tid % 32 == 0) {
s_data[tid / 32] = sum;
}
__syncthreads();
// 最后一个warp处理块间结果
if (tid < 32) {
sum = s_data[tid];
sum = warpReduce(sum);
if (tid == 0) g_out[blockIdx.x] = sum;
}
}
优势:
- 无需Shared Memory,节省资源
- 延迟极低(仅1-2个时钟周期)
- 代码简洁,易于维护
五、卷积优化:把前面学的东西串起来
卷积是深度学习里最常见的操作,优化好了能直接影响整个模型的训练/推理速度。这周正好拿2D卷积练手,把Stream、Constant Memory、Texture Memory这些技术都用上了。
从最简单的版本一直优化到综合各种技术的版本,最后性能提升了3.6倍。这个过程让我深刻体会到:优化不是靠某个单一技巧,而是要系统性地分析瓶颈、选择合适的方法。
5.1 问题定义
输入矩阵:input[HEIGHT][WIDTH]
卷积核:kernel[KERNEL_SIZE][KERNEL_SIZE](如5x5)
输出:output[HEIGHT][WIDTH]
基本计算:
output[y][x] = Σ Σ input[y+j][x+i] * kernel[j][i]
5.2 Version 1:最朴素的实现
先写个最简单的版本当baseline:
cpp
__global__ void convNaive(float* output, float* input,
float* kernel, int width, int height) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < height) {
float sum = 0.0f;
int half = KERNEL_SIZE / 2;
for (int ky = 0; ky < KERNEL_SIZE; ky++) {
for (int kx = 0; kx < KERNEL_SIZE; kx++) {
int ix = x + kx - half;
int iy = y + ky - half;
// 边界检查
if (ix >= 0 && ix < width && iy >= 0 && iy < height) {
sum += input[iy * width + ix] *
kernel[ky * KERNEL_SIZE + kx];
}
}
}
output[y * width + x] = sum;
}
}
问题在哪儿:
- Global Memory访问太多:每个输出像素要读25次输入(5x5的核)
- 数据重复读取:相邻的线程会读到很多重叠的数据,但每个线程都傻傻地自己读一遍
- 没利用任何缓存
跑了个2048x2048的图像,5x5的核,花了12.6ms。这就是我们的baseline。
5.3 Version 2:用上Constant Memory
想到卷积核很小(5x5才25个float),而且所有线程都要用,这不就是Constant Memory的典型场景吗?
cpp
__constant__ float const_kernel[KERNEL_SIZE * KERNEL_SIZE];
// Host端初始化
cudaMemcpyToSymbol(const_kernel, h_kernel,
KERNEL_SIZE * KERNEL_SIZE * sizeof(float));
__global__ void convConstant(float* output, float* input,
int width, int height) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < height) {
float sum = 0.0f;
int half = KERNEL_SIZE / 2;
for (int ky = 0; ky < KERNEL_SIZE; ky++) {
for (int kx = 0; kx < KERNEL_SIZE; kx++) {
int ix = x + kx - half;
int iy = y + ky - half;
if (ix >= 0 && ix < width && iy >= 0 && iy < height) {
// 从Constant Memory读取核
sum += input[iy * width + ix] *
const_kernel[ky * KERNEL_SIZE + kx];
}
}
}
output[y * width + x] = sum;
}
}
改完测了下,10.8ms,快了1.17倍。提升不算特别大,但考虑到只改了几行代码,性价比还行。
5.4 Version 3:Shared Memory发力
这个优化是大头。核心思路是:把一块输入数据(连带边界)先加载到Shared Memory,然后这个block里的所有线程都从Shared Memory读,避免重复访问Global Memory。
这就是所谓的Tiling策略。
cpp
#define TILE_SIZE 16
#define BLOCK_SIZE (TILE_SIZE + KERNEL_SIZE - 1) // 含边界:16+4=20
__global__ void convShared(float* output, float* input,
int width, int height) {
__shared__ float s_input[BLOCK_SIZE][BLOCK_SIZE];
int tx = threadIdx.x;
int ty = threadIdx.y;
int x = blockIdx.x * TILE_SIZE + tx;
int y = blockIdx.y * TILE_SIZE + ty;
int half = KERNEL_SIZE / 2;
// 协作加载数据到Shared Memory(含边界)
for (int i = ty; i < BLOCK_SIZE; i += blockDim.y) {
for (int j = tx; j < BLOCK_SIZE; j += blockDim.x) {
int gx = blockIdx.x * TILE_SIZE + j - half;
int gy = blockIdx.y * TILE_SIZE + i - half;
if (gx >= 0 && gx < width && gy >= 0 && gy < height) {
s_input[i][j] = input[gy * width + gx];
} else {
s_input[i][j] = 0.0f; // 边界填充
}
}
}
__syncthreads();
// 计算(从Shared Memory读取)
if (tx < TILE_SIZE && ty < TILE_SIZE && x < width && y < height) {
float sum = 0.0f;
for (int ky = 0; ky < KERNEL_SIZE; ky++) {
for (int kx = 0; kx < KERNEL_SIZE; kx++) {
sum += s_input[ty + ky][tx + kx] *
const_kernel[ky * KERNEL_SIZE + kx];
}
}
output[y * width + x] = sum;
}
}
几个关键点:
- 数据重用率高:一个输入元素会被多个输出元素用到,现在只需要从Global Memory读一次
- Global Memory访问量暴降:从每个输出读25次降到每个输出读1次(均摊下来)
- 要注意Bank Conflict:我调整了下Shared Memory的布局,避免bank conflict
这版跑出来4.2ms,比baseline快了3倍!这才对嘛。
5.5 Version 4:试试Texture Memory
Texture Memory天生就是为2D访问优化的,图像卷积正好符合这个场景。而且Texture的边界处理是硬件自动做的,省了不少代码。
cpp
__global__ void convTexture(float* output, cudaTextureObject_t texInput,
int width, int height) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < height) {
float sum = 0.0f;
int half = KERNEL_SIZE / 2;
for (int ky = 0; ky < KERNEL_SIZE; ky++) {
for (int kx = 0; kx < KERNEL_SIZE; kx++) {
// Texture fetch,自动处理边界
float val = tex2D<float>(texInput,
x + kx - half,
y + ky - half);
sum += val * const_kernel[ky * KERNEL_SIZE + kx];
}
}
output[y * width + x] = sum;
}
}
测了下3.8ms,比Shared Memory版本还快一点(3.3倍加速)。
Shared Memory vs Texture怎么选:
- Shared Memory:代码写起来稍微麻烦点,但你能精确控制每一步
- Texture Memory:硬件帮你优化好了,写代码简单,但相对"黑盒"一些
- 实际项目里看情况选。我个人倾向于先试Texture,不行再上Shared Memory手动优化
5.6 完整性能对比
| 版本 | 执行时间 | 加速比 | 带宽利用率 | 关键优化 |
|---|---|---|---|---|
| Naive | 12.6ms | 1.0x | 45% | - |
| Constant Memory | 10.8ms | 1.17x | 52% | 卷积核缓存 |
| Shared Memory | 4.2ms | 3.0x | 78% | 数据重用 |
| Texture Memory | 3.8ms | 3.3x | 81% | 硬件缓存 |
| Shared + Texture | 3.5ms | 3.6x | 85% | 混合策略 |
测试环境:RTX 3060, 2048x2048图像, 5x5卷积核
六、性能分析工具:不能只靠猜
之前优化主要靠"感觉",这周学会了用专业工具定位问题。有工具和没工具,效率差太多了。
6.1 nvprof:命令行下的性能分析神器
nvprof是NVIDIA自带的profiler,虽然新版推荐用Nsight Compute,但nvprof简单粗暴,适合快速查问题。
基本用法:
bash
# 基础性能分析
nvprof ./my_cuda_app
# 详细kernel信息
nvprof --print-gpu-trace ./my_cuda_app
# 度量特定指标
nvprof --metrics achieved_occupancy,gld_efficiency ./my_cuda_app
# 事件分析
nvprof --events l1_cache_global_hit_rate ./my_cuda_app
看哪些指标:
-
Occupancy(占用率):有多少warp在同时跑
- 一般大于50%就行,不用追求100%(有时候反而慢)
- 太低的话可能是寄存器或Shared Memory用太多了
-
Memory Throughput(内存吞吐):
gld_efficiency:Global Load效率gst_efficiency:Global Store效率- 这俩指标低说明内存访问有问题,可能没对齐或者有bank conflict
-
Instruction Throughput:
ipc(每周期执行多少指令)- IPC低说明在等内存或者指令之间有依赖
6.2 Nsight Compute:深度剖析利器
这个工具比nvprof强大多了,信息详细到有点吓人。不过习惯了之后真香。
Nsight Compute提供了更详细的分析界面:
bash
# GUI模式
ncu-ui
# 命令行模式
ncu --set full -o profile_output ./my_cuda_app
最有用的几个功能:
- Roofline分析:一眼看出你的kernel是算得慢还是内存慢
- Memory Workload分析:L1/L2 Cache命中率、带宽用了多少
- Compute Workload分析:指令吞吐、warp调度效率
- Source View:能把性能数据直接标到源代码上,哪行慢一目了然
实际案例:
我用Nsight Compute分析卷积kernel,发现:
- L1 Cache命中率只有68%,这说明该用Shared Memory了
- Memory Replay是1.8x,这是bank conflict的表现
- 改完之后L1命中率上升到92%,Replay降到1.05x,性能立刻上去了
七、经验总结
7.1 各种技术该什么时候用
学了这么多技术,什么场景用什么很重要。我整理了个表格:
| 技术 | 适用场景 | 性能收益 | 复杂度 |
|---|---|---|---|
| CUDA Stream | 大数据量处理,可分块 | 中-高 | 低 |
| Constant Memory | 小型只读数据,所有线程访问 | 低-中 | 极低 |
| Texture Memory | 2D/3D空间局部性访问 | 中 | 低 |
| Shared Memory | 块内数据重用 | 高 | 中 |
| Atomic操作 | 并发写入,低冲突 | 低-中 | 低 |
| Warp Shuffle | Warp内通信 | 中 | 中 |
7.2 我的优化套路
经过这周的学习,总结了一套比较靠谱的优化流程:
内存优化优先级(从高到低):
-
先搞定访问模式
- 保证内存访问是合并的(coalesced access)
- 干掉bank conflict
- 能用向量类型(float4)就用
-
利用数据局部性
- 热点数据扔Shared Memory缓存起来
- Tiling策略减少Global Memory访问
-
特殊内存该用就用
- 小的只读数据 → Constant Memory
- 2D/3D访问 → Texture Memory
-
异步执行
- 多个Stream让传输和计算同时跑
- 流水线并行
计算优化:
- Occupancy优化:线程数、寄存器、Shared Memory三者要平衡
- 指令优化 :用内置函数(
__fdividef,rsqrtf),比自己写快 - 分支优化:尽量减少warp内的分支分化
我的优化流程:
1. 先用nvprof看看哪里慢
↓
2. 大概率是内存问题,先优化内存(收益最大)
↓
3. 再看计算部分
↓
4. Nsight Compute深度分析
↓
5. 继续迭代
说实话,内存优化的收益远大于计算优化,所以内存问题一定要先解决。
八、写在最后
8.1 这周学到了什么
Week 3的内容确实比前两周硬核多了。Stream、Constant Memory、Texture Memory这些特性之前只是听说过,这周终于真正理解并用上了。
最大的收获不是学会了几个API,而是建立起了系统性的优化思维。比如做卷积优化,一开始我只想着"用Shared Memory肯定快",但实际测下来发现Texture Memory也很香,关键是要根据具体场景选择。从12.6ms优化到3.5ms,这3.6倍的提升背后是对GPU硬件特性的深入理解。
几点体会:
- 并发思维:Stream让我意识到GPU的潜力远不止并行计算,还能做很多overlapping的事情
- 内存层次很重要:寄存器、Shared Memory、Constant/Texture Memory、Global Memory,每一层的性能差异都是数量级的
- 工具必不可少:有profiler和没profiler完全是两个效率。盲目优化就是浪费时间
- 没有银弹:每种优化技术都有适用场景,要学会权衡
8.2 下一步计划
Week 3结束,感觉CUDA的基础优化技术基本掌握了。接下来要进入更专业的领域:
Week 4-5的目标:
- Flash Attention(这个在LLM推理里太重要了)
- Fused Kernel(把多个操作合并减少访存)
- 量化和混合精度
路还很长,但每周都能看到自己的进步,这种感觉挺爽的。继续加油吧!
代码和实验数据我都放到了GitHub,有问题欢迎一起讨论!
觉得有帮助的话,点个赞呗 👍
有问题评论区见~