CUDA 算子优化:Transpose

2296 字
11 分钟
CUDA 算子优化:Transpose
2026-08-09

Transpose 即矩阵转置。它几乎没有计算量,性能主要取决于内存访问效率,因此是理解 CUDA Global Memory 合并访问和 Shared Memory Bank Conflict 的经典算子。

Naive 实现#

假设输入矩阵为 M×NM \times N

ARM×NA \in \mathbb{R}^{M \times N}

Transpose 的结果为:

B=ATRN×MB = A^T \in \mathbb{R}^{N \times M}

最直接的实现是让一个线程负责一个元素:

__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(合并访问)

Note

关于“一个 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 的问题可以概括为:

Important

Global Memory 合并读,但非合并写。

Transpose 的第一个优化目标自然也就出现了:

Important

能不能让 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

也就是说:

  1. 从 Global Memory 中连续读取一个 tile;
  2. 将 tile 放入 Shared Memory;
  3. 在 Shared Memory 中完成转置;
  4. 将转置后的 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。

因此,相邻两行之间相隔:

32×4=128 bytes32\times4=128\text{ bytes}

在 bank 映射上,这意味着相邻行的相同列会落到相同的 bank。

于是一个 warp 的访问可能变成:

thread 0 → bank 0
thread 1 → bank 0
thread 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。

这种方法虽然简单,但它揭示了一个很重要的思想:

Important

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。

因此,PaddingSwizzling 的本质其实是一样的:

Important

通过改变 Shared Memory 中的数据布局,避免多个线程访问同一个 bank。

优化思路总结#

Transpose 的优化过程其实非常典型:

Transpose 优化过程总结
Transpose 优化过程总结

从这个过程可以看到,Transpose 本身没有复杂的计算,优化的核心始终围绕着数据如何移动:

  1. Global Memory:尽可能保证 warp 的访问连续,减少非合并访问;
  2. Shared Memory:利用片上高速存储重新组织数据布局;
  3. Bank Conflict:避免一个 warp 中的线程集中访问同一个 bank;
  4. 最终目标:让 Global Memory 的读写都保持合并访问,同时让 Shared Memory 的访问尽可能无冲突。

这也是 Transpose 最经典的 CUDA 优化模式:

Important

用 Shared Memory 把两个方向的 Global Memory 访问解耦,再通过数据布局解决 Shared Memory 的 Bank Conflict。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
CUDA 算子优化:Transpose
https://llm-tech.com.cn/posts/cuda-transpose/
作者
Ming
发布于
2026-08-09
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Ming
你是来找 Ming 学习的吗
🎉 欢迎来到 Ming 的博客
这里是我的个人博客,分享 AI Infra、LLM 等技术内容。欢迎关注交流!
分类
标签
站点统计
文章
19
分类
8
标签
16
总字数
55,114
运行时长
0
最后活动
0 天前

目录