CUDA 算子优化:Transpose
Transpose 即矩阵转置。它几乎没有计算量,性能主要取决于内存访问效率,因此是理解 CUDA Global Memory 合并访问和 Shared Memory Bank Conflict 的经典算子。
Naive 实现
假设输入矩阵为 :
Transpose 的结果为:
最直接的实现是让一个线程负责一个元素:
__global__ void transpose(float *input, float *output, int M, int N) { int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < M && col < N) { output[col * M + row] = input[row * N + col]; }}对于线程(row, col),它完成:
A[row][col] → B[col][row]看起来很简单,但这个实现存在一个典型问题:Global Memory 的读写访问模式不一致。
读取是合并的
考虑一个 warp 中的线程,一般 threadIdx.x 是连续变化的,而 threadIdx.y 是保持不变的,这些线程读取:
input[row][0]input[row][1]input[row][2]...input[row][31]也就是连续的内存地址。
因此,读取属于 coalesced access(合并访问)。
关于“一个 warp 中的线程,一般 threadIdx.x 是连续变化的,而 threadIdx.y 是保持不变的”,你可以运行下面的 CUDA 代码进行验证。
一般 blockDim.x 设置成 32 的倍数就是为了保持这一良好特性。
#include <iostream>#include <cuda_runtime.h>
__global__ void printThreadInfo() { // 计算一维线程 ID int tid = threadIdx.x + threadIdx.y * blockDim.x + threadIdx.z * blockDim.x * blockDim.y; // 计算所属的 Warp ID int warpId = tid / 32;
// 只打印前两个 warp 的信息,避免输出过多 if (warpId < 2) { printf("Warp %d | tid=%2d | threadIdx.x=%2d | threadIdx.y=%d\n", warpId, tid, threadIdx.x, threadIdx.y); }}
int main() { std::cout << "=== Case 1: 1D Block (blockDim.x = 64) ===" << std::endl; dim3 block1D(64); printThreadInfo<<<1, block1D>>>(); cudaDeviceSynchronize();
std::cout << "\n=== Case 2: 2D Block (blockDim.x = 16, blockDim.y = 4) ===" << std::endl; dim3 block2D(16, 4); printThreadInfo<<<1, block2D>>>(); cudaDeviceSynchronize();
return 0;}写入却是不合并的
写入地址为:
output[col * M + row]当一个 warp 中的 col 持续变化时,实际访问的是:
output[0][row]output[1][row]output[2][row]...output[31][row]也就是矩阵的一列。
如果矩阵较大,相邻线程访问的地址之间会相隔很远:
thread 0 → output[0][row]thread 1 → output[1][row]thread 2 → output[2][row]...因此,这些访问无法很好地合并。
所以 Naive Transpose 的问题可以概括为:
Global Memory 合并读,但非合并写。
Transpose 的第一个优化目标自然也就出现了:
能不能让 Global Memory 的读写都变成合并访问?
调整线程映射
一种直观的思路是:既然 Naive 实现的问题出在写入,那就调整线程与矩阵元素之间的对应关系,让线程直接按照输出矩阵的布局进行计算。
__global__ void transpose(const float* input, float* output, int M, int N) { int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x;
// output 为 N × M if (row < N && col < M) { output[row * M + col] = input[col * N + row]; }}现在一个 warp 中的线程写入:
output[row][0]output[row][1]output[row][2]...output[row][31]因此写入变成了连续访问。
但是,读取 input 的方式也随之发生了变化:
input[0][row]input[1][row]input[2][row]...读取变成了跨行访问。
也就是说,我们只是把问题从:
合并读 + 非合并写变成了:
非合并读 + 合并写仍然无法同时优化读和写。
这也说明,对于 Transpose 来说,仅仅调整线程映射是不够的。
Shared Memory:在读写之间增加一个“中转站”
如果希望 Global Memory 同时实现合并读和合并写,就需要将 Global Memory 的访问与矩阵转置操作解耦。
一个经典的方法是使用 Shared Memory:
Global Memory │ │ 合并读取 ↓Shared Memory │ │ 在片上完成转置 ↓Global Memory │ │ 合并写入 ↓Output也就是说:
- 从 Global Memory 中连续读取一个 tile;
- 将 tile 放入 Shared Memory;
- 在 Shared Memory 中完成转置;
- 将转置后的 tile 连续写回 Global Memory。
这样,Global Memory 的读写都可以保持合并访问。

代码如下:
template <const int BLOCK_SIZE>__global__ void transpose(const float* input, float* output, int M, int N){ __shared__ float s_mem[BLOCK_SIZE][BLOCK_SIZE];
int bx = blockIdx.x * BLOCK_SIZE; int by = blockIdx.y * BLOCK_SIZE;
int x1 = bx + threadIdx.x; int y1 = by + threadIdx.y; // Global Memory → Shared Memory
if (x1 < N && y1 < M) { s_mem[threadIdx.y][threadIdx.x] = input[y1 * N + x1]; } __syncthreads();
int x2 = by + threadIdx.x; int y2 = bx + threadIdx.y; // Shared Memory → Global Memory
if (x2 < M && y2 < N) { output[y2 * M + x2] = s_mem[threadIdx.x][threadIdx.y]; }}调用方式:
dim3 block(32, 32);dim3 grid(CEIL(N, 32), CEIL(M, 32));
transpose<32><<<grid, block>>>(input, output, M, N);到这里,Global Memory 的问题基本解决了:
Global Memory ↓ 合并读 ↓Shared Memory ↓ 转置 ↓ 合并写 ↓Global Memory但仔细观察 Shared Memory 的访问,又会发现一个新的问题。
Shared Memory Bank Conflict
Shared Memory 并不是一块完全平坦的存储空间,而是划分成多个 bank。 对于一个 warp,如果不同线程访问不同 bank,这些访问可以并行完成。 但如果多个线程同时访问同一个 bank,就会产生 bank conflict,访问需要被拆分,从而降低 Shared Memory 的有效带宽。
Transpose 恰好很容易触发这个问题。
写入 Shared Memory 没有问题
首先看:
s_mem[threadIdx.y][threadIdx.x]一个 warp 中,threadIdx.x 连续变化,因此访问的是同一行:
s_mem[0][0]s_mem[0][1]s_mem[0][2]...s_mem[0][31]这些元素连续存储,可以映射到不同的 bank。
因此,这一步没有明显的 bank conflict。
转置后读取产生 Bank Conflict
问题出现在:
s_mem[threadIdx.x][threadIdx.y]此时一个 warp 中的线程访问的是同一列:
s_mem[0][0]s_mem[1][0]s_mem[2][0]...s_mem[31][0]对于:
__shared__ float s_mem[32][32];每行包含 32 个 float。
因此,相邻两行之间相隔:
在 bank 映射上,这意味着相邻行的相同列会落到相同的 bank。
于是一个 warp 的访问可能变成:
thread 0 → bank 0thread 1 → bank 0thread 2 → bank 0...thread 31 → bank 0也就是典型的 32-way bank conflict。 [bank conflict]
所以现在的问题变成了:
Global Memory ↓ 合并读 ↓Shared Memory ↓ Bank Conflict ↓ 合并写接下来需要解决 Shared Memory 的 bank conflict。
Padding:给每一行增加一个元素
最经典的解决方法是给 Shared Memory 增加一个 padding:
__shared__ float s_mem[32][33];也就是:
原来: 32 × 32 __shared__ float s_mem[BLOCK_SIZE][BLOCK_SIZE];
现在: 32 × 33 __shared__ float s_mem[BLOCK_SIZE][BLOCK_SIZE + 1];完整代码:
template <const int BLOCK_SIZE>__global__ void transpose(const float* input, float* output, int M, int N){ __shared__ float s_mem[BLOCK_SIZE][BLOCK_SIZE + 1];
int bx = blockIdx.x * BLOCK_SIZE; int by = blockIdx.y * BLOCK_SIZE;
int x1 = bx + threadIdx.x; int y1 = by + threadIdx.y; // Global Memory → Shared Memory
if (x1 < N && y1 < M) { s_mem[threadIdx.y][threadIdx.x] = input[y1 * N + x1]; } __syncthreads();
int x2 = by + threadIdx.x; int y2 = bx + threadIdx.y; // Shared Memory → Global Memory
if (x2 < M && y2 < N) { output[y2 * M + x2] = s_mem[threadIdx.x][threadIdx.y]; }}为什么仅仅增加一个元素就可以解决问题?
原来的行跨度是:32 elements
padding 后变成:33 elements
因此读取:
s_mem[0][0]s_mem[1][0]s_mem[2][0]...s_mem[31][0]时,相邻线程访问的 bank 不再完全重合,而是依次错开,从而避免了严重的 bank conflict。
这种方法虽然简单,但它揭示了一个很重要的思想:
Shared Memory 中的数据布局会影响 bank 映射。改变数据布局,就可以改变线程访问的 bank。
Swizzling:改变数据布局
除了 padding,还可以直接改变数据在 Shared Memory 中的布局。
一种常见方法是使用 XOR 进行 swizzling:
threadIdx.x ^ threadIdx.y代码如下:
template <const int BLOCK_SIZE>__global__ void transpose(float* input, float* output, int M, int N) { __shared__ float s_mem[BLOCK_SIZE][BLOCK_SIZE]; // 不需要padding
int bx = blockIdx.x * BLOCK_SIZE; int by = blockIdx.y * BLOCK_SIZE;
int x1 = bx + threadIdx.x; int y1 = by + threadIdx.y;
if (x1 < N && y1 < M) { s_mem[threadIdx.y][threadIdx.x ^ threadIdx.y] = input[y1 * N + x1]; } __syncthreads();
int x2 = by + threadIdx.x; int y2 = bx + threadIdx.y;
if (x2 < M && y2 < N) { output[y2 * M + x2] = s_mem[threadIdx.x][threadIdx.x ^ threadIdx.y]; // swizzling后,此处不存在bank conflict }}这里 Shared Memory 仍然是:
__shared__ float s_mem[32][32];并没有额外增加 padding。
区别在于,线程不再简单地使用:
s_mem[y][x]而是使用:
s_mem[y][x ^ y]也就是说,我们并没有改变矩阵转置的逻辑,只是改变了数据在 Shared Memory 中的物理布局。
这样可以重新组织 bank 的映射关系,从而避免转置读取时产生严重的 bank conflict。
因此,Padding 和 Swizzling 的本质其实是一样的:
通过改变 Shared Memory 中的数据布局,避免多个线程访问同一个 bank。
优化思路总结
Transpose 的优化过程其实非常典型:

从这个过程可以看到,Transpose 本身没有复杂的计算,优化的核心始终围绕着数据如何移动:
- Global Memory:尽可能保证 warp 的访问连续,减少非合并访问;
- Shared Memory:利用片上高速存储重新组织数据布局;
- Bank Conflict:避免一个 warp 中的线程集中访问同一个 bank;
- 最终目标:让 Global Memory 的读写都保持合并访问,同时让 Shared Memory 的访问尽可能无冲突。
这也是 Transpose 最经典的 CUDA 优化模式:
用 Shared Memory 把两个方向的 Global Memory 访问解耦,再通过数据布局解决 Shared Memory 的 Bank Conflict。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!