手写 CUDA FlashAttention(一):朴素实现
1 朴素的Attention
1.1 Attention简介
关于 Attention 的介绍强烈推荐李宏毅老师的教学视频:自注意力机制和Transformer详细解析。
1.1.1 从输入 到 、 、
记模型的输入是长度 的序列 ,其中每个元素 都是一个 维的向量:
从输入prompt到得到 ,分为以下几个步骤:
- 输入的句子经过分词器(tokenizer)处理,转换成一系列离散的token。
- 每一个token被映射到一个高维空间中,形成一个向量,这个过程叫做嵌入(embedding)。嵌入层将每个token转换成一个 维的向量。
将 个 token 转换成向量后,纵向排列成一个 的矩阵 。
然后将每一个 token 的向量 ,映射到查询(Query)、键(Key)和值(Value)三个不同的空间中:
从线性代数的角度看, 本质上定义了一个从原始表示空间 到查询空间 的线性映射。对每个 token 向量 ,乘以 相当于将它投影到一个专门用于“提问”的子空间中。
将所有token看做一个整体,一起做映射,将公式简化为:
1.1.2 Attention公式拆解
Attention计算公式如下:
我们逐步拆解这个公式。
1.
的第 行 是 在查询空间中的表示, 的第 行 是 在键空间中的表示.
向量的点积衡量了两个向量的相似度/相关性。直观的讲, 衡量了 对 的重要程度.
2.
为什么这里要除以 呢?
假设: 和 的每个分量都是独立的、均值为 、方差为 的随机变量。
点积展开:
分析每一项:每个 是两个独立的零均值、单位方差随机变量的乘积:
每个 是两个独立的零均值、单位方差随机变量的乘积:
对 d 项求和,由于各分量独立,方差可加:
所以点积的标准差为 。当 很大时(如 64、128),点积的值会很大,导致 softmax 输出趋近 one-hot 向量(梯度接近 0,训练困难)。
除以 后:
3.
对上一步得到的缩放点积结果,沿最后一个维度(即 Key 的序列维度)求 Softmax,将注意力分数归一化为概率分布。
直接计算 时,如果 很大,会导致浮点溢出。实践中使用 safe softmax:先减去每行的最大值 ,再取指数:
减去 不改变 softmax 的结果(分子分母同乘 可以约掉),但保证了指数的输入 ,避免数值溢出。
归一化后,每一行的注意力权重之和为 1,表示当前 Query 对所有 Key 的关注程度分布。
4.
将注意力权重与 矩阵相乘,得到最终的加权输出:
其中输出矩阵 的每一行 ,即对所有 Value 向量按注意力权重进行加权求和。直觉上,模型通过注意力分数决定”关注哪些位置的信息”,再从这些位置提取对应的 Value 进行聚合。
2 编写朴素Attention的 cuda 算子
首先定义好基础结构,分配 d_S d_P 作为中间变量:
#include <cmath>
#include <cfloat>
#include <cstdio>
#include <cuda_runtime.h>
#define TILE 16
#define BLOCK_SIZE 256
#define cdiv(a, b) (((a) + (b) - 1) / (b))
void launch_naive_attention(
const float* d_Q, const float* d_K, const float* d_V, float* d_O,
int N, int d
) {
float* d_S, *d_P;
cudaMalloc(&d_S, sizeof(float) * N * N);
cudaMalloc(&d_P, sizeof(float) * N * N);
// 1. S = Q K^T / sqrt(d)
dim3 block1(TILE, TILE);
dim3 grid1(cdiv(N, TILE), cdiv(N, TILE));
ScaledDotProductKernel<<<grid1, block1>>>(d_Q, d_K, d_S, N, d);
// 2. P = softmax(S); per row
dim3 block2(BLOCK_SIZE);
dim3 grid2(cdiv(N, BLOCK_SIZE));
SoftmaxKernel<<<grid2, block2>>>(d_S, d_P, N);
// 3. O = P V
dim3 block3(TILE, TILE);
dim3 grid3(cdiv(N, TILE), cdiv(d, TILE));
PVMultiplyKernel<<<grid3, block3>>>(d_P, d_V, d_O, N, d);
cudaFree(d_S);
cudaFree(d_P);
}
2.1 ScaledDotProductKernel
第一阶段,计算 ,本质是一个标准的矩阵乘法。输出矩阵 是 ,每个线程负责计算一个输出元素 ,因此按 TILE × TILE 划分 thread block,再根据输出矩阵的大小确定 grid 维度:
dim3 block1(TILE, TILE);
// 输出矩阵 S 是 N×N,每个线程计算一个 S_ij,grid 按 TILE 分块覆盖整个输出矩阵
dim3 grid1(cdiv(N, TILE), cdiv(N, TILE));
ScaledDotProductKernel<<<grid1, block1>>>(
d_Q, d_K, d_S, N, d
);
device 侧,注意 d_Q 和 d_K 在显存中的布局都是 (行优先),我们并没有真正构造 。计算 时,只需让 Q 按第 row 行、K 按第 col 行去取同一维度 i 的元素做内积即可:
__global__ void ScaledDotProductKernel(const float* d_Q, const float* d_K, float* d_S, int N, int d) {
int row = threadIdx.x + blockIdx.x * blockDim.x;
int col = threadIdx.y + blockIdx.y * blockDim.y;
if (row < N && col < N) {
float sum = 0.0f;
for (int i = 0; i < d; i++) {
// Q[row, i] * K[col, i] (K[col] 即 K^T 的第 col 列)
sum += d_Q[row * d + i] * d_K[col * d + i];
}
d_S[row * N + col] = sum / sqrtf((float)d);
}
}
2.2 SoftmaxKernel
第二阶段对 S 逐行求 softmax。Softmax 是按行独立的操作,所以每个线程处理一整行,不需要二维 block:
dim3 block2(BLOCK_SIZE);
dim3 grid2(cdiv(N, BLOCK_SIZE));
SoftmaxKernel<<<grid2, block2>>>(
d_S, d_P, N
);
device 侧,每个线程负责 S 的一行,内部分三步:求最大值 m、求指数和、归一化写回 P。注意 expf 中要减去 m 防止溢出:
__global__ void SoftmaxKernel(const float* d_S, float* d_P, int N) {
int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row < N) {
// 1. 求本行最大值
float m = -FLT_MAX;
for (int i = 0; i < N; i++) {
m = fmaxf(m, d_S[row * N + i]);
}
// 2. 求指数和 (减去 m 防止溢出)
float sum = 0.0f;
for (int i = 0; i < N; i++) {
sum += expf(d_S[row * N + i] - m);
}
// 3. 归一化,写回 P
for (int i = 0; i < N; i++) {
d_P[row * N + i] = expf(d_S[row * N + i] - m) / sum;
}
}
}
2.3 PVMultiplyKernel
第三阶段计算 ,又是一个标准矩阵乘法。 是 , 是 ,输出 是 。与第一阶段类似,按输出矩阵的形状配置 grid:
dim3 block3(TILE, TILE);
dim3 grid3(cdiv(N, TILE), cdiv(d, TILE));
PVMultiplyKernel<<<grid3, block3>>>(
d_P, d_V, d_O, N, d
);
device 侧,每个线程计算 :
__global__ void PVMultiplyKernel(const float* d_P, const float* d_V, float* d_O, int N, int d) {
int row = threadIdx.x + blockIdx.x * blockDim.x;
int col = threadIdx.y + blockIdx.y * blockDim.y;
if (row < N && col < d) {
float sum = 0.0f;
for (int i = 0; i < N; i++) {
sum += d_P[row * N + i] * d_V[i * d + col];
}
d_O[row * d + col] = sum;
}
}
3 通用的优化算法
我们先不使用 FlashAttention 用到的算法,从更常用的cuda优化方案思考上述代码的优化空间。
3.1 分块矩阵乘法
dim3 block1(TILE, TILE);
dim3 grid1(cdiv(N, TILE), cdiv(N, TILE));
ScaledDotProductKernel<<<grid1, block1>>>(
d_Q, d_K, d_S, N, d
);
__global__ void ScaledDotProductKernel(const float* d_Q, const float* d_K, float* d_S, int N, int d) {
int row = threadIdx.x + blockIdx.x * blockDim.x;
int col = threadIdx.y + blockIdx.y * blockDim.y;
if (row < N && col < N) {
float sum = 0.0f;
for (int i = 0; i < d; i++) {
sum += d_Q[row * d + i] * d_K[col * d + i];
}
d_S[row * N + col] = sum / sqrtf((float)d);
}
}
重新审视这段代码的访存模式。一个 block 有 TILE × TILE 个线程,它们共同计算输出矩阵 S 中一个 TILE × TILE 的子块。为了计算这个子块,同一行的 TILE 个线程都需要读取 Q 的同一行,同一列的 TILE 个线程都需要读取 K 的同一行——也就是说,每一行 Q 数据被同 block 内的 TILE 个线程重复读取,每一行 K 数据同理。
朴素实现中,这些重复读取全部走 HBM(显存),而 HBM 的带宽是 GPU 计算的主要瓶颈。解决方案是利用每个 SM 上的 shared memory(共享内存/SRAM):block 内的线程先协作地将所需数据从 HBM 搬运到 shared memory,之后所有线程都从 shared memory 读取,避免重复的 HBM 访问。
但 shared memory 容量有限(通常 48~164 KB),无法一次装下 Q 和 K 沿 d 维度的完整行。因此需要分块(tiling):将 d 维度切成若干个大小为 TILE 的片段,每次只加载一小块到 shared memory,计算出部分内积并累加,循环直到遍历完整个 d 维度。每个 block 需要分配 2 块 TILE × TILE 的 shared memory 空间,分别缓存 Q 和 K 的当前 tile。
__global__ void ScaledDotProductKernel(const float* d_Q, const float* d_K, float* d_S, int N, int d) {
__shared__ float s_Q[TILE][TILE];
__shared__ float s_K[TILE][TILE];
int tx = threadIdx.x;
int ty = threadIdx.y;
int row = tx + blockIdx.x * TILE;
int col = ty + blockIdx.y * TILE;
float sum = 0.0f;
for (int i = 0; i < cdiv(d, TILE); i++) {
// 第一步,视角切换,block内每个线程管理一个s_Q和s_K的线程
if (row < N && i * TILE + ty < d) {
// s_Q[tx][ty] = Q[row][i*TILE + ty]
s_Q[tx][ty] = d_Q[row * d + i * TILE + ty];
} else {
s_Q[tx][ty] = 0.0f;
}
if (col < N && i * TILE + tx < d) {
// s_K[tx][ty] = K^T[i*TILE + tx][col] = K[col][i*TILE + tx]
s_K[tx][ty] = d_K[col * d + i * TILE + tx];
} else {
s_K[tx][ty] = 0.0f;
}
__syncthreads(); // 同步,所有线程现在的状态都一样,才可以重新分工。
// 视角切换。一个线程用于计算 S[row][col] 的值
for (int k = 0; k < TILE; k++) {
sum += s_Q[tx][k] * s_K[k][ty];
}
__syncthreads(); // 同步,线程马上要重新分工去读数据。
}
if (row < N && col < N) {
d_S[row * N + col] = sum / sqrtf((float)d);
}
}
同理,修改第三步 PVMultiplyKernel:
__global__ void PVMultiplyKernel(const float* d_P, const float* d_V, float* d_O, int N, int d) {
int tx = threadIdx.x;
int ty = threadIdx.y;
int row = tx + blockIdx.x * blockDim.x;
int col = ty + blockIdx.y * blockDim.y;
__shared__ float s_P[TILE][TILE];
__shared__ float s_V[TILE][TILE];
float sum = 0.0f;
for (int i = 0; i < cdiv(N, TILE); i++) {
if (row < N && i * TILE + ty < N) {
s_P[tx][ty] = d_P[row * N + i * TILE + ty];
} else {
s_P[tx][ty] = 0.0f;
}
if (i * TILE + tx < N && col < d) {
s_V[tx][ty] = d_V[(i * TILE + tx) * d + col];
} else {
s_V[tx][ty] = 0.0f;
}
__syncthreads();
for (int k = 0; k < TILE; k++) {
sum += s_P[tx][k] * s_V[k][ty];
}
__syncthreads();
}
if (row < N && col < d) {
d_O[row * d + col] = sum;
}
}
3.2 并行规约
SoftmaxKernel 中,现在每一个线程负责一行的 S ,需要 的遍历每一行 3 次,并发性太低了。
重新分析 Softmax 我们需要做什么:
-
获取每一行的最大值
之前的做法是,每个Block计算一行,让1个线程遍历这一行。这一个线程就需要递增 次。
如果可以同时让
BLOCK_SIZE个线程一起计算最大值,递增的次数就减少了BLOCK_SIZE倍。一个线程负责
N / BLOCK_SIZE个数据,统计一个局部最大值。所有线程执行完成后,得到
BLOCK_SIZE个局部最大值。之后再对这BLOCK_SIZE个局部最大值求整体最大值。因此我们需要
BLOCK_SIZE * sizeof(float)的空间存局部最大值,N个BLOCK每个BLOCK负责一行,每块BLOCK_SIZE个线程。// 2. P = softmax(S); dim3 block2(BLOCK_SIZE); dim3 grid2(N); SoftmaxKernel<<<grid2, block2>>>( d_S, d_P, N ); __global__ void SoftmaxKernel(const float* d_S, float* d_P, int N) { int row = blockIdx.x; int tid = threadIdx.x; __shared__ float sdata[BLOCK_SIZE]; // 1. 求局部最大值 sdata[tid] = -FLT_MAX; for (int i = tid; i < N; i += BLOCK_SIZE) { sdata[tid] = fmax(sdata[tid], d_S[row * N + i]); } }这里每个线程反复对 sdata[tid] 进行读写,虽然 SRAM 的读取速度已经很快了,但是寄存器的读写速度更高。
可以做一个简单的优化:// 1. 求局部最大值 float local_max = -FLT_MAX; for (int i = tid; i < N; i += BLOCK_SIZE) { local_max = fmax(local_max, d_S[row * N + i]); } sdata[tid] = local_max;之后对
sdata中BLOCK_SIZE个局部最大值进行归约。此时每个线程的职责要发生变换,所以需要先同步。__syncthreads(); // 归约,求整行的最大值 for (int stride = BLOCK_SIZE / 2; stride >= 1; stride >>= 1) { if (tid < stride) { sdata[tid] = fmax(sdata[tid], sdata[tid + stride]); } __syncthreads(); } float m = sdata[0];
-
指数求和
同样的两步走,先求局部和,再用归约的算法求和。
float local_sum = 0.0f; for (int i = tid; i < N; i += BLOCK_SIZE) { local_sum += expf(d_S[row * N + i] - m); } sdata[tid] = local_sum; __syncthreads(); for (int stride = BLOCK_SIZE / 2; stride >= 1; stride >>= 1) { if (tid < stride) { sdata[tid] += sdata[tid + stride]; } __syncthreads(); } float sum = sdata[0]; -
逐个元素的自然指数除以指数和
for (int i = tid; i < N; i += BLOCK_SIZE) { d_P[row * N + i] = expf(d_S[row * N + i] - m) / sum; }
3.3 线程粗化(COARSE)
下面回过头,继续优化分块矩阵乘法。
参考3.1的动画,计算 时,分块矩阵乘法的核心公式为:
以 为例,展开得:
可以看到, 在计算 和 时都会被用到:
也就是说, 这个 TILE 被两组不同的线程分别从全局内存读取了两次。
线程粗化(Thread Coarsening)的思路是:既然 反正要被读进共享内存,那就让同一组线程一次性把 和 都算完,而不是分给两组线程各算一个。
具体来说,原本两个线程块分别计算:
- 线程块 A:读取 ,计算
- 线程块 B:读取 ,计算
粗化之后,一个线程块同时完成:
- 读取 (只读一次)
- 计算 和
这样, 从全局内存到共享内存的读取次数从 2 次降为 1 次,减少了冗余的全局内存访问。
dim3 block1(TILE, TILE);
dim3 grid1(cdiv(N, TILE), cdiv(N, TILE * COARSE));
__global__ void ScaledDotProductKernel(const float* d_Q, const float* d_K, float* d_S, int N, int d) {
int tx = threadIdx.x;
int ty = threadIdx.y;
int row = threadIdx.x + blockIdx.x * TILE;
int col = threadIdx.y + blockIdx.y * TILE * COARSE;
__shared__ float s_Q[TILE][TILE];
__shared__ float s_K[TILE][TILE];
float sum[COARSE] = {};
for (int i = 0; i < cdiv(d, TILE); i++) {
if (row < N && i * TILE + ty < d)
s_Q[tx][ty] = d_Q[row * d + i * TILE + ty];
else
s_Q[tx][ty] = 0.0f;
#pragma unroll
for (int c = 0; c < COARSE; c++) {
int cur_col = col + c * TILE;
if (cur_col < N && i * TILE + tx < d)
s_K[tx][ty] = d_K[cur_col * d + i * TILE + tx];
else
s_K[tx][ty] = 0.0f;
__syncthreads();
for (int k = 0; k < TILE; k++) {
sum[c] += s_Q[tx][k] * s_K[k][ty];
}
__syncthreads();
}
}
float scale = 1.0f / sqrtf((float)d);
for (int c = 0; c < COARSE; c++) {
int cur_col = col + c * TILE;
if (row < N && cur_col < N)
d_S[row * N + cur_col] = sum[c] * scale;
}
}
同理,对 PVMultiplyKernel 也可以做线程粗化。
性能对比
到此为止,我们对比一下原版和四个优化的性能差异
| N | 朴素实现 | 分块矩阵乘法 | 并行归约 | 线程粗化 |
|---|---|---|---|---|
| 256 | 0.134 ms | 0.106 ms | 0.062 ms | 0.122 ms |
| 512 | 0.533 ms | 0.418 ms | 0.318 ms | 0.410 ms |
| 1024 | 1.453 ms | 1.063 ms | 0.836 ms | 0.929 ms |
| 16384 | 160.555 ms | 89.030 ms | 63.558 ms | 56.797 ms |
注意到引入了线程粗化后,N 较小时(256~1024),性能反而比并行归约差。这是因为线程粗化用 COARSE=4 将每个线程块的工作量扩大了 4 倍,同时线程块总数减少为原来的 1/4。当 N 较小时,线程块本来就不多,再减少 4 倍后 GPU 的 SM 无法被充分占满,并行度不足反而拖慢了速度。到 N=16384 时,线程块数量足够多,粗化减少的冗余读取才真正体现出优势。
很有成就感,性能逐步提升了接近3倍。接下来,我们继续。
3.4 合并访存(COALESCING)
其实前面的代码,都有一个关键的性能问题。
CUDA 线程线性化顺序是 tid = tx + ty * blockDim.x,所以,同一个 warp 里,tx = 0..15,ty 固定。
而前面的代码中:
row = tx + blockIdx.x * TILE
s_Q[tx][ty] = d_Q[row * d + i*TILE + ty]
相邻线程的地址间隔是 d,不连续,非合并访问。
所以概念中我们应该记住,tx一般是列,ty是行,如果有第三个维度,tz是深度。应该让 tx 永远保持在最外层,代码从 tx 的角度看是连续的。
修复方法是:交换 tx 和 ty 的角色,让 tx 对应列,ty 对应行。这样同一 warp 内相邻线程(tx 连续)访问的是连续的内存地址。
row = ty + blockIdx.y * TILE
col = tx + blockIdx.x * TILE
s_Q[ty][tx] = d_Q[row * d + i*TILE + tx]
相邻线程(tx 连续)访问 d_Q[row * d + ...] 的连续偏移,实现合并访存。
相应地,host 侧也要调换 grid 的 x、y 维度:
dim3 block1(TILE, TILE);
dim3 grid1(cdiv(N, TILE * COARSE), cdiv(N, TILE));
K 的转置访问方式变化
Q 的修改很直观,但 K 需要额外思考一下。我们要加载的是 的一个 TILE,忽略块内偏移,只看块号:
k_row = i * TILE
k_col = blockIdx.x * TILE * COARSE + c * TILE
按 的视角加载:
s_K[ty][tx] = d_KT[k_row + ty][k_col + tx]
转置回 :
s_K[ty][tx] = d_K[k_col + tx][k_row + ty] // 这里展开就是 3.3 的写法
但这里 tx 在行序(外层索引)上,同一 warp 内 tx 连续会导致不同行的访问,不是合并访存。
方法一:所以我们交换 d_K 下标中的 tx 和 ty:
s_K[ty][tx] = d_K[k_col + ty][k_row + tx] // tx 到了内层,合并访存
这个交换不会改变取的是哪个 TILE,只是让 TILE 内部多了一次转置——存进共享内存的数据相比 3.3 是转置的。
没关系,后续计算时取 就转回来了:
// 3.3: s_Q[tx][k] * s_K[k][ty] — 直接用 s_K
// 3.4: s_Q[ty][k] * s_K[tx][k] — 用 s_K 的转置,抵消加载时多出的转置
两次转置抵消,最终结果正确。
方法二:或者,我们可以在读取的时候,对s_K做一次转置:
s_K[ty][tx] = d_K[k_col + ty][k_row + tx]
s_K[tx][ty] = d_K[k_col + ty][k_row + tx] // 写入s_K时转置
后续计算时就不用再次转置了,还是:
s_Q[tx][k] * s_K[k][ty]
3.5 Bank Confict
3.4 最后提到的对 K 的两种处理方式,乍一看感觉本质上完全一样吧?我们实际测试一下。
| N | 线程粗化 | 方法一:计算时再转置 | 方法二:先转置 |
|---|---|---|---|
| 256 | 0.122 ms | 0.081 ms | 0.077 ms |
| 512 | 0.407 ms | 0.324 ms | 0.310 ms |
| 1024 | 0.929 ms | 0.756 ms | 0.708 ms |
| 16384 | 57.096 ms | 33.187 ms | 24.386 ms |
非常的 Amazing 啊,这一个微小的改动竟然有这么明显地性能提升。
要理解方法二为什么更快,需要先了解 Bank Conflict。
Shared Memory 的物理结构
Shared memory 被切成 32 个 bank,每个 bank 是独立的内存模块,可以同时服务一个请求。
地址分配规则很简单:第 n 个 float 在第 n % 32 号 bank。
地址: 0 1 2 3 ... 31 32 33 ...
bank: 0 1 2 3 ... 31 0 1 ...
理想情况:一个 warp 32 个线程同时读 shared memory,如果每个线程访问不同的 bank,32 个 bank 并行服务,一个周期搞定。
Bank Conflict:一个 bank 在同一周期只能服务一个线程。如果多个线程访问同一个 bank,硬件会串行化这些请求。N-way conflict = 该 bank 同时被 N 个线程访问 = 需要 N 个周期。
具体分析 s_K 的两种读取方式
方法一 s_K[tx][k],k=3 固定,tx=0..15(同一个 warp 的前半部分):
s_K[0][3] → offset = 0*16+3 = 3 → bank 3
s_K[1][3] → offset = 1*16+3 = 19 → bank 19
s_K[2][3] → offset = 2*16+3 = 35 → bank 3 ← 撞 tx=0
s_K[3][3] → offset = 3*16+3 = 51 → bank 19 ← 撞 tx=1
s_K[4][3] → offset = 4*16+3 = 67 → bank 3 ← 撞
...
s_K[7][3] → offset = 7*16+3 = 115 → bank 19 ← 撞
bank 3 被 8 个线程同时访问 → 硬件串行执行 8 次 → 8-way conflict,慢 8 倍。
根本原因:tx 每增加 1,offset 增加 TILE=16。而 16 % 32 = 16,32 % 32 = 0,每隔两个线程就回到同一 bank,16 个线程只踩 2 个 bank。
方法二 s_K[k][tx],k=3 固定,tx=0..15:
s_K[3][0] → offset = 3*16+0 = 48 → bank 16
s_K[3][1] → offset = 3*16+1 = 49 → bank 17
s_K[3][2] → offset = 3*16+2 = 50 → bank 18
...
s_K[3][15] → offset = 3*16+15 = 63 → bank 31
tx 每增加 1,offset 增加 1,bank 也增加 1 → 16 个线程访问 16 个不同 bank,无冲突。
Bank conflict 的根源是步长。步长是 32 的因数时出问题;步长是 1(连续访问)时最好。
s_K[tx][k]:tx 走在”行”方向,步长=TILE=16 → 冲突s_K[k][tx]:tx 走在”列”方向,步长=1 → 无冲突
Padding 消除 Bank Conflict
还有一个更简单的方法:把 s_K[TILE][TILE] 改成 s_K[TILE][TILE+1]。
读取 s_K[tx][k] 的问题本质是步长 16 是 32 的因数。加了 +1 之后,每行占 17 个 float:
s_K[tx][k], k=3 固定, tx=0..15:
offset = tx * 17 + 3
tx=0: 3 → bank 3
tx=1: 20 → bank 20
tx=2: 37 → bank 5
tx=3: 54 → bank 22
tx=4: 71 → bank 7
tx=5: 88 → bank 24
...
步长从 16 变成 17,而 17 和 32 互质(gcd(17,32)=1),所以 16 个线程一定映射到 16 个不同 bank,冲突消失。
修改之后方法一和方法二的性能基本一致了:
| N | 线程粗化 | 方法一:计算时再转置 | 方法二:先转置 |
|---|---|---|---|
| 256 | 0.119 ms | 0.074 ms | 0.073 ms |
| 512 | 0.373 ms | 0.271 ms | 0.272 ms |
| 1024 | 0.892 ms | 0.669 ms | 0.672 ms |
| 16384 | 58.079 ms | 23.597 ms | 25.350 ms |