CUDA学习之路[11]:矩阵乘法计算详解

5469 字
27 分钟
CUDA学习之路[11]:矩阵乘法计算详解
更新已完成

写在前边#

Note

写了这么久,终于写到了矩阵运算,后续的代码普遍都比较长,比较综合,完整的代码仓库在这里,希望对大家有所帮助和收获!

什么是矩阵乘法#

无论是卷积层、全连接层还是注意力计算,最终都会归因到矩阵运算。 无论是C语言还是CUDA亦或者是numpy、pytorch,都有一大堆针对矩阵运算来进行优化的库,这也是我们AI Infra真正意义上的起点,跟随着我的视角,我们来完整的体会一下是如何优化的。

我们先来看一下CPU版本的矩阵运算:

void sgemm(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;
}
}
}

先分析一下这个算法的时间复杂度,三个for循环,是O(N3)O(N^3)

好,我们已经学过了CUDA、Triton、Pytorch。他们分别是从线程、块、张量的角度来进行分析的。

那矩阵乘法应该如何优化呢? 所有优化的核心都只有两点:

  1. 优化访存
  2. 优化计算

Pytorch#

在Pytorch中,有非常多的计算矩阵乘法的方式,最常见的是以下几种:

@与*的区别#

对于两个相同尺寸的张量来说,*运算和@或者matmul运算是不同的。

*运算符表示各个元素直接相乘,例如a*b,它的结果直接就是对应元素相乘。 但是@运算符表示的是矩阵相乘

a = torch.tensor([1,2])
b = torch.tensor([2,3])
print(a*b)
print(torch.matmul(a, b))
print(a @ b)
# tensor([2, 6])
# tensor(8)
# tensor(8)

矩阵相乘的若干写法#

除了@和matmul,在Pytorch中,还有很多中矩阵相乘的写法,对于两个向量来说,dot、@、matmul这三种写法都是等价的,其中dot只接受一维向量计算。

# @ 最常见的运算符
v1 = torch.tensor([1.0, 2.0, 3.0])
v2 = torch.tensor([4.0, 5.0, 6.0])
# 以下三种写法完全等价
print(torch.matmul(v1, v2))
print(v1 @ v2)
print(torch.dot(v1, v2)) # dot 只接受 1D

其中对于纯二维矩阵来说,以下三种写法是比较常见的,mm表示纯二维矩阵乘。

# 纯二维矩阵乘法
a = torch.randn(100, 200)
b = torch.randn(200, 300)
c = torch.mm(a, b)
d = torch.matmul(a, b)
e = a @ b

对于批量三维矩阵乘法来说,bmm是更加高效的选择:

# 批量三维矩阵乘法 (Batch, seq, Feature)
batch_x = torch.randn(10, 16, 64)
batch_w = torch.randn(10, 64, 128)
y = torch.bmm(batch_x, batch_w)

还有能够显示指定的方案:

# einsum爱因斯坦求和,通过下标字符串实现复杂的乘法、转置以及缩并
Q = torch.randn(2, 8, 10, 64) # (batch, heads, len_q, d_k)
K = torch.randn(2, 8, 10, 64)
# 计算 QK^T
attn = torch.einsum('b h q d, b h k d -> b h q k', Q, K) # (2,8,10,10)
为什么有这么多种不同的写法呢?

对于mm、bmm、dot来说,这些是最底层的算子,只接受严格的对应维度的张量,它们的低层是基于Lua的Torch7。而Matmul更加广泛,支持多种维度的张量相乘,并且同样支持广播机制。例如(10,3,4)可以和(4, 5)的张量相乘,会把后者当成(1,4,5)并且张量到(10,4,5)来计算。而@则是matmul的语法糖。但是matmul在真正执行前需要跑一大段C++的推断,多了一些检查策略。并且广播机制真的很容易引发报错。但是高纬度张量,还不得不使用这个,要不然就得疯狂使用view和transpose。至于einsum,对于高维张量来了说,这个方法我感觉是最清晰的。

@的语法糖

如果没有@,对于A,B,C这种连乘,就得写torch.matmul(torch.matmul(A, B), C), 有了@直接写 A @ B @ C,优雅~

Triton#

参考博客

Triton的核心分为两个模块,Pytorch封装和Kernel代码。

先写Pytorch封装端,做好维度检查与内存申请:

def matmul(a: torch.Tensor, b: torch.Tensor, BLOCK_SIZE_M=64, BLOCK_SIZE_N=64, BLOCK_SIZE_K=32) -> torch.Tensor:
assert a.ndim == 2 and b.ndim == 2, "只支持二维矩阵"
assert a.shape[1] == b.shape[0], f"维度不匹配:{a.shape}{b.shape}"
M, K = a.shape
K, N = b.shape
# 2. 申请C的内存
c = torch.empty((M, N), device=a.device, dtype=a.dtype)

在做好准备工作之后,需要把大矩阵切分成为很多个Block块,每一个块都让GPU去算:

# M 方向的块数 = ceil(M / BLOCK_SIZE_M)
# N 方向的块数 = ceil(N / BLOCK_SIZE_N)
# 这是一个匿名函数,meta 里面会传入 BLOCK_SIZE 的实际值
grid = lambda meta: (
triton.cdiv(M, meta["BLOCK_SIZE_M"]) * triton.cdiv(N, meta["BLOCK_SIZE_N"]),
)

做好grid切分之后,启动Kernel,这里要注意一个细节,通常在传入参数的时候,需要以下内容:

  1. 张量指针
  2. 张量形状
  3. 内存步长

之前讲过,在底层,矩阵是一维数组,并且有可能是非连续,因此显式传入步长会更有效。

matmul_kernel_v0[grid](
a, b, c, # 传张量(Triton会自动提取它们的显存首地址指针)
M, N, K, # 传矩阵真实大小
a.stride(0), a.stride(1), # A 的行步长、列步长
b.stride(0), b.stride(1), # B 的行步长、列步长
c.stride(0), c.stride(1), # C 的行步长、列步长
BLOCK_SIZE_M=BLOCK_SIZE_M, # 传编译期常量
BLOCK_SIZE_N=BLOCK_SIZE_N,
BLOCK_SIZE_K=BLOCK_SIZE_K,
)
return c

在写好了启动函数之后,接下来就是针对一个具体的BLOCK来写Kernel函数。

常见的Triton思路还是:

  1. 计算pid
  2. 计算offset和mask
  3. load
  4. 计算
  5. store

具体来说:

# 查看每一个block的pid
pid = tl.program_id(axis=0)
# 算一下 C 矩阵一行有多少个块?
num_pid_n = tl.cdiv(N, BLOCK_SIZE_N)
# 把一维块变成二维坐标 (行块号, 列块号)
pid_m = pid // num_pid_n
pid_n = pid % num_pid_n
# 计算当前pid具体要负责原矩阵 C 的哪些行号和列号?
# tl.arange(0, 64) 生成 [0, 1, ... 63]
off_m = pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M) # 比如 [64, 65... 127]
off_n = pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N) # 比如 [0, 1... 63]
# K 是我们要循环的维度,每次处理一个小段
off_k = tl.arange(0, BLOCK_SIZE_K)

做好数据处理后,需要算出对应的显存地址,这里使用到了广播机制:

# off_m[:, None] 变成 64x1 的列向量,乘以行步长
# off_k[None, :] 变成 1x32 的行向量,乘以列步长
a_ptrs = a_ptr + off_m[:, None] * stride_am + off_k[None, :] * stride_ak
b_ptrs = b_ptr + off_k[:, None] * stride_bk + off_n[None, :] * stride_bn
# 寄存器,全部填 0,用来一会做累加,为了不掉精度,这里用 float32
acc = tl.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=tl.float32)

之后沿着K的维度来进行计算:

for k in range(0, tl.cdiv(K, BLOCK_SIZE_K)):
# 边界检查:如果 K 不是 32 的整数倍,最后一步可能会越界
k_remain = K - k * BLOCK_SIZE_K
# 生成Mask。如果坐标越界了,那对应的地方就是 False
a_mask = (off_m[:, None] < M) & (off_k[None, :] < k_remain)
b_mask = (off_k[:, None] < k_remain) & (off_n[None, :] < N)
# 从显存搬砖到SRAM。遇到越界的地方 (Mask=False),就用 0.0 填补
a = tl.load(a_ptrs, mask=a_mask, other=0.0)
b = tl.load(b_ptrs, mask=b_mask, other=0.0)
# 算这一个小块的矩阵乘法,结果加到acc 上
acc += tl.dot(a, b, allow_tf32=False)
# 算完这一段了,把 A 和 B 的指针往 K 的方向推进一步,去拿下一段数据
a_ptrs += BLOCK_SIZE_K * stride_ak
b_ptrs += BLOCK_SIZE_K * stride_bk

最终写回到C中:

c_ptrs = c_ptr + off_m[:, None] * stride_cm + off_n[None, :] * stride_cn
# 15. C 的边界保护遮罩
c_mask = (off_m[:, None] < M) & (off_n[None, :] < N)
# 把 float32 的寄存器结果,转回和矩阵 C 一样的数据类型 (比如 float16)
tl.store(c_ptrs, acc.to(c_ptr.dtype.element_ty), mask=c_mask)
完整的代码仓库地址在这里:

至于V1版本,实际上可以进行Group优化,那么在这里,大家可以参考Triton官方的版本,写的也是非常不错的,我就不画蛇添足了。

CUDA#

V0 Naive#

在CPU的实现中,两个外层循环负责遍历输出矩阵C的所有元素,最内层循环负责进行点积运算。

在CPU中

计算C[i][j]的过程是顺序的,先算C[0][0], 再算C[0][1]…

GPU的思路是什么呢?把M×NM \times N个输出元素的计算任务,一次性分配给M×NM \times N个线程。每个线程负责计算对应的C[row][col],各自独立地从A取一整行,从B取一整列,完成K次乘加。

矩阵乘法中输出元素之间是没有数据依赖的,这就是典型的element wise并行模式。GPU Naive正是利用了这一点,用并行机制消除了两层外层循环。

最基本的V0代码如下:

__global__ void sgemm_v0(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.f;
for (int k = 0; k < K; ++k)
sum += A[row * K + k] * B[k * N + col];
C[row * N + col] = sum;
}
}

V1 Tile版本#

V0版本解决了两层for循环的问题,那么它如何进行性能提升呢?

大家还记得GPU上分为了HBM、Shared Memory、Register至少三级存储吗? 在V0这种模式下,同一个数据会被不同的线程反复读取,这种读取会占用大量的带宽,并且数据也没有被进行复用。

举个例子:

计算C[0][0]
线程读取A的第0行,读取B的第0列
计算C[0][1]
线程读取A的第0行,读取B的第1列

理论上,A的一行会被N个线程各读一遍,B的一列也会被M个线程各读一遍,这导致了巨大的冗余访存,所以这也是我们优化的方向。

一个很自然的想法是,既然A的行和B的列会被多次用到,如果我们能够先搬一部分到更快的共享内存上,各自的线程先计算一部分,这样就能够大幅度减少全局内存访问。

请看如下演示:

具体的计算流程如下
  1. 把输出矩阵分成若干[BM,BN]的块
  2. 计算过程中,A和B沿着K轴分成BK大小的块
  3. 每次迭代把A[BM][BK]和B[BK][BN]加载到共享内存中
  4. 线程块内所有线程从共享内存中读数据,完成局部乘加
  5. 循环到下一个K分块,直到遍历完整个K

每个A、B中的元素被加载到共享内存后,可以由线程块内BM×BNBM \times BN个线程共用,全局访存量下降为原来的(1/(BM * BN))量级(粗略估计),缓解带宽瓶颈。

具体的Kernel代码如下:

__global__ void sgemm_v1(const float* A, const float* B, float* C, int M, int N, int K) {
constexpr int BM = 32, BN = 32, BK = 32;
__shared__ float SA[BM][BK], SB[BK][BN];
// 单个Block内部的偏移量
int ty = threadIdx.y;
int tx = threadIdx.x;
// C[row][col]
int row = blockIdx.y * BM + ty;
int col = blockIdx.x * BN + tx;
float sum = 0.0f;
for (int tile = 0; tile < K; tile += BK) {
int ga = tile + tx;
SA[ty][tx] = (row < M && ga < K) ? A[row * K + ga] : 0.0f;
int gb = tile + ty;
SB[ty][tx] = (gb < K && col < N) ? B[gb * N + col] : 0.0f;
__syncthreads();
#pragma unroll
for (int k = 0; k < BK; k++) {
sum += SA[ty][k] * SB[k][tx];
}
__syncthreads();
}
if (row < M && col < N) {
C[row * N + col] = sum;
}
}

性能对比如下:

v0-naive 0.4501 ms 4.77 TFLOPS ref
v1-smem 0.3552 ms 6.05 TFLOPS 0.00e+00

V1 1D Thread#

在V1中,每个线程负责一个32*32的输出子块,线程块也是32*32,恰好一个线程负责一个输出元素。 在一次Tile迭代中,只做了2次global load,进行了32次shared memory load,还有32次FMA,然后就开始sync。 线程的大部分时间都在等待同步、等待load完成,计算密度太低。

此时优化的点是什么呢?

优化访存我们已经在v1做过了,那是不是接下来就要尝试着优化计算了呢?

实际上,1D Tiling的核心思想是让一个线程负责多个元素,而不是一个。 原本1024个线程对应1024个位置,但是实际上我们可以让一个线程负责多个位置的计算。

线程(ty=0,tx=0)可以负责row[0, 15], 线程(ty=1,tx=0)负责row[16, 31]…

所有的(ty, tx)线程的列 col = blockCol + tx

这就是我们这个方法的核心,通过增加计算密度,还overlap访存效率的低下。

示意图如下:

具体代码如下:

template <int BM = 128, int BN = 64, int BK = 4, int TM = 16>
__global__ void sgemm_v1_1(const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int M, int N, int K) {
constexpr int NT = (BM / TM) * BN;
__shared__ float SA[BM][BK];
__shared__ float SB[BK][BN];
int tx = threadIdx.x;
int ty = threadIdx.y;
int col = blockIdx.x * BN + tx;
float sum[TM] = {0.0f};
int tid = ty * blockDim.x + tx;
for (int tile = 0; tile < K; tile += BK) {
#pragma unroll
for (int i = tid; i < BM * BK; i += NT) {
int load_a_row = i / BK;
int load_a_col = i % BK;
int ga_row = blockIdx.y * BM + load_a_row;
int ga_col = tile + load_a_col;
SA[load_a_row][load_a_col] = (ga_row < M && ga_col < K) ? A[ga_row * K + ga_col] : 0.0f;
}
#pragma unroll
for (int i = tid; i < BK * BN; i += NT) {
int load_b_row = i / BN;
int load_b_col = i % BN;
int gb_row = tile + load_b_row;
int gb_col = blockIdx.x * BN + load_b_col;
SB[load_b_row][load_b_col] = (gb_row < K && gb_col < N) ? B[gb_row * N + gb_col] : 0.0f;
}
__syncthreads();
#pragma unroll
for (int k = 0; k < BK; k++) {
float b_val = SB[k][tx];
#pragma unroll
for (int m = 0; m < TM; m++) {
sum[m] += SA[ty * TM + m][k] * b_val;
}
}
__syncthreads();
}
#pragma unroll
for (int m = 0; m < TM; m++) {
int row = blockIdx.y * BM + ty * TM + m;
if (row < M && col < N) {
C[row * N + col] = sum[m];
}
}
}

核心加载数据其实也没有发生明显变化,最为核心的还是在单个线程的计算上,之前是一行一列。

而现在一列要对应16行,例如选出一个B的元素,把它放在寄存器中,之后就和A的16行来进行计算,这样就进一步提升了线程的计算量。

V1 2D Thread#

既然我们已经把使用单个线程把B的元素复用了,那实际上A的元素也可以明显复用,这样对于一个线程,复用率会更多,有点难以想象?看一下这个动画:

在1D的时候,是两个向量进行乘加,最终是一个标量,也就是内积:

矩阵乘法的内积形式
矩阵乘法的内积形式

但是矩阵乘法也可以等价地写成外积的形式。

矩阵乘法的外积形式
矩阵乘法的外积形式

在此感谢大佬的仔细讲解

外积把A变成列的形式,把B变成行的形式,这样同时取A的列,B的行,就完成了一次外积,然后把所有的A列和所有的B行算出来的矩阵加起来,这就是外积的形式。

例如:

A = [[1,1],
[3,4]]
B = [[2,2],
[3,4]]
# 内积形式
C[0][1] = 1*2 + 1*3 = 5
# 外积形式
C[0] = [1,3]^T @ [2,2] = [[2,2],
[6,6]]

根据这个外积,我们就可以完整的把2D Tiling完整的写出来:

template <int BM = 128, int BN = 128, int BK = 16, int TM = 8, int TN = 8>
__global__ void sgemm_v1_2(const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int M, int N, int K) {
constexpr int NT = (BM / TM) * (BN / TN);
__shared__ float SA[BM][BK];
__shared__ float SB[BK][BN];
int tx = threadIdx.x;
int ty = threadIdx.y;
int tid = ty * blockDim.x + tx;
float sum[TM][TN] = {{0.f}};
for (int tile = 0; tile < (K + BK - 1) / BK; ++tile) {
int gk = tile * BK;
#pragma unroll
for (int i = tid; i < BM * BK; i += NT) {
int load_a_row = i / BK;
int load_a_col = i % BK;
int ga_row = blockIdx.y * BM + load_a_row;
int ga_col = gk + load_a_col;
SA[load_a_row][load_a_col] = (ga_row < M && ga_col < K) ? A[ga_row * K + ga_col] : 0.f;
}
#pragma unroll
for (int i = tid; i < BK * BN; i += NT) {
int load_b_row = i / BN;
int load_b_col = i % BN;
int gb_row = gk + load_b_row;
int gb_col = blockIdx.x * BN + load_b_col;
SB[load_b_row][load_b_col] = (gb_row < K && gb_col < N) ? B[gb_row * N + gb_col] : 0.f;
}
__syncthreads();
#pragma unroll
for (int k = 0; k < BK; ++k) {
float a_reg[TM], b_reg[TN];
#pragma unroll
for (int m = 0; m < TM; ++m) a_reg[m] = SA[ty * TM + m][k];
#pragma unroll
for (int n = 0; n < TN; ++n) b_reg[n] = SB[k][tx * TN + n];
#pragma unroll
for (int m = 0; m < TM; ++m)
#pragma unroll
for (int n = 0; n < TN; ++n)
sum[m][n] += a_reg[m] * b_reg[n];
}
__syncthreads();
}
#pragma unroll
for (int m = 0; m < TM; ++m) {
int row = blockIdx.y * BM + ty * TM + m;
if (row >= M) continue;
#pragma unroll
for (int n = 0; n < TN; ++n) {
int col = blockIdx.x * BN + tx * TN + n;
if (col < N) C[row * N + col] = sum[m][n];
}
}
}

到此为止的性能对比情况:

kernel time TFLOPS vs ref
v0-naive 0.4817 ms 4.46 TFLOPS ref
v1-smem 0.3805 ms 5.64 TFLOPS 0.00e+00
v1-1Dtiling 0.2414 ms 8.90 TFLOPS 0.00e+00
v1-2Dtiling 0.1314 ms 16.34 TFLOPS 0.00e+00

V2 Tile#

关于计算,我们已经相比较原来提升了约十几倍,那么,还有什么地方可以提升呢?

之前是我们把数据从HMB搬到了Shared Memory,对于“搬运”这个动作来说,实际上也可以提升,例如——————————————

大声告诉我是什么!

向量化!

对,就是向量化,把所有和数据搬运相关的,都改成向量化即可:

template <int BM = 64, int BN = 64, int BK = 32, int TM = 8, int TN = 4>
__global__ void sgemm_v2(const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int M, int N, int K) {
static_assert(BK % 4 == 0, "BK must be multiple of 4 for float4");
static_assert(BN % 4 == 0, "BN must be multiple of 4 for float4");
static_assert(TN % 4 == 0, "TN must be multiple of 4 for float4");
constexpr int NT = (BM / TM) * (BN / TN);
__shared__ float SA[BM][BK];
__shared__ float SB[BK][BN];
int tx = threadIdx.x;
int ty = threadIdx.y;
int tid = ty * blockDim.x + tx;
float sum[TM][TN] = {{0.f}};
for (int tile = 0; tile < (K + BK - 1) / BK; ++tile) {
int gk = tile * BK;
#pragma unroll
for (int i = tid; i < BM * BK / 4; i += NT) {
int load_a_row = i / (BK / 4);
int load_a_col = (i % (BK / 4)) * 4;
int ga_row = blockIdx.y * BM + load_a_row;
int ga_col = gk + load_a_col;
if (ga_row < M && ga_col < K)
*(float4*)&SA[load_a_row][load_a_col] =
*(const float4*)&A[ga_row * K + ga_col];
else
*(float4*)&SA[load_a_row][load_a_col] =
make_float4(0.f, 0.f, 0.f, 0.f);
}
#pragma unroll
for (int i = tid; i < BK * BN / 4; i += NT) {
int load_b_row = i / (BN / 4);
int load_b_col = (i % (BN / 4)) * 4;
int gb_row = gk + load_b_row;
int gb_col = blockIdx.x * BN + load_b_col;
if (gb_row < K && gb_col < N)
*(float4*)&SB[load_b_row][load_b_col] =
*(const float4*)&B[gb_row * N + gb_col];
else
*(float4*)&SB[load_b_row][load_b_col] =
make_float4(0.f, 0.f, 0.f, 0.f);
}
__syncthreads();
#pragma unroll
for (int k = 0; k < BK; ++k) {
#pragma unroll
for (int n = 0; n < TN; n += 4) {
float4 b_val = *(float4*)&SB[k][tx * TN + n];
#pragma unroll
for (int m = 0; m < TM; ++m) {
float a_val = SA[ty * TM + m][k];
sum[m][n + 0] += a_val * b_val.x;
sum[m][n + 1] += a_val * b_val.y;
sum[m][n + 2] += a_val * b_val.z;
sum[m][n + 3] += a_val * b_val.w;
}
}
}
__syncthreads();
}
#pragma unroll
for (int m = 0; m < TM; ++m) {
int row = blockIdx.y * BM + ty * TM + m;
if (row >= M) continue;
#pragma unroll
for (int n = 0; n < TN; n += 4) {
int col = blockIdx.x * BN + tx * TN + n;
if (col < N)
*(float4*)&C[row * N + col] =
make_float4(sum[m][n], sum[m][n + 1],
sum[m][n + 2], sum[m][n + 3]);
}
}
}

V3 双缓冲#

无论是之前的Norm还是其他系列,我都建议,只要是稍微复杂一些的load和store,涉及到向量化的,都可以把他封装起来,这样效果会比较好。

已经有了向量化和2D Tile,接下来还有什么优化空间呢?

我先卖个关子,可以先看一下最终优化的效果:

CUDA SGEMM — v0 to v6 optimization journey
GPU: NVIDIA GeForce RTX 4090 D | SMs: 114 | sm_89
--- SGEMM M=N=K=4096 (2.1 GFLOPs) ---
kernel time TFLOPS vs ref
v0-naive 0.4817 ms 4.46 TFLOPS ref
v1-smem 0.3804 ms 5.65 TFLOPS 0.00e+00
v1-1Dtiling 0.1672 ms 12.84 TFLOPS 0.00e+00
v1-2Dtiling 0.1148 ms 18.70 TFLOPS 0.00e+00
v2-tile 0.0735 ms 29.23 TFLOPS 0.00e+00
v3-dbuf 0.0687 ms 31.25 TFLOPS 0.00e+00
cuBLAS(FP32) 0.0650 ms 33.05 TFLOPS 1.57e-03

最终的V3版本大概只和cuBLAS有0.002ms的性能差距,甚至cuBLAS还偷偷用到了其他硬件模块进行加速。

那这个效果可以说是非常不错。

是怎么实现的呢? 在V2中,sA布局是行优先,和Global A的布局是一致的,但是在访问的时候:

float a_val = SA[ty * TM + m][k]
// 线程ty=0,m=0读SA[0][k]
// 线程ty=0, m=1读SA[1][k]

我们发现,SA[0][K]和SA[1][k]在内存中相距BK个float,这是不连续的,因此没办法做到float4读取,只能在B中float,而在A中无法这样做。

怎么解决呢?——转置存储 只需要把A的一行散射到SA的4个不同的行即可,这样就访问的是连续的行号:

*(float4*)(av+m) = *(const float4*)&SA[rb][k][ty * TM + m];

除了转置之外,Double Buffering也是一个非常关键的思路。

什么意思,我们具体来看:

V2的执行流:
tile 0: load SA, SB -> sync -> compute -> sync
tile 1: load SA, SB -> sync -> compute -> sync
...

如果学过体系结构的同学已经看出来可以优化的点了:Pipeline。

实际上Load和Compute是严格拆串行的,但是Tile0和Tile1之间的关联又没有那么明显,因此我们可以尝试pipeline:

V3的执行流:
tile i : global -> register 加载
tile i-1: shared -> register 计算
...

这两个同时发生,因此访存被进一步隐藏。

最终的性能想必大家也看到了,基本在cuBLAS的95%左右,当然,我还需要微调模板参数,来让他性能达到最优,这里我就先按下不表。

到此为止,和矩阵乘法的CUDA相关的内容就写的差不多了,代码仓库在文章开头,大家可以自行下载,多多Star,感谢大家的支持! 希望对大家有所帮助。

支持与分享

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

赞助
CUDA学习之路[11]:矩阵乘法计算详解
https://dlog.com.cn/posts/cuda11/matmul/
作者
杜子源
发布于
2026-06-11
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
杜子源
都是风景,幸会
公告
请狠狠地打赏我,打赏一次,爆更一篇!!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
29
分类
8
标签
11
总字数
93,152
运行时长
0
最后活动
0 天前

目录