LLM Systems Assignment 4: Transformer CUDA Acceleration
本次 homework 主要内容是:实现 softmax 与 LayerNorm kernel fusion 形式的 fwd 与 bwd,完成 CUDA kernel 编写;接入 Python 层;接入 Transformer 模型并验证训练加速。
下面对本次 homework 实现时一些注意点进行介绍。
1. Softmax
1.1 forward
课程已经提供了 block_dim <= 32 时的 kernel
实现代码,我们只需要实现支持 block_dim > 32 版本的
kernel。
在开始实现 kernel 之前,我们首先要理解 kernel 的 thread/block 组织以及它们如何协作完成 softmax forward 计算。
在 launch_attn_softmax 这个 C 语言接口函数中进行了
kernel launch,查看其中的代码可以理解线程任务划分:
1 | |
kernel 的线程任务划分已经在上述代码注释中进行了说明,这里重复解释如下:
- 输入 tensor 形状为
(batch_size, nhead, from_len, to_len),其中batch_size与nhead都很好理解,就是训练时 batch_size 与 Attention 的 head 数量。from_len与to_len则分别表示 query 与 key 的 token 数量。由于 minitorch 是一个仅考虑训练的教学性质框架,实际上在我们的 homework 中进行 GPT-2 模型训练时,softmax kernel 的输入参数一定满足from_len == to_len。 - 在理解输入 tensor 形状的基础上,softmax forward kernel
要做的事情自然很清楚了:沿着
(batch_size, nhead, from_len)三个互相独立的维度,在to_len维度做to_len个元素的 reduce,进行 softmax 计算。 - 然后我们就可以映射输入 tensor shape 到 launch kernel 时的
blockDim/gridDim 参数了:
gridDim的y与z取值分别为batch_size与nhead,表示输入 tensor 最外层独立的两个维度;gridDim的x取值与from_len有关。虽然from_len维度与 softmax 计算过程也是独立的,但是由于from_len通常小于等于to_len(也就是 query token 数量肯定小于等于 key token 数量),因此在实际进行 kernel launch 时,gridDim.x的取值根据to_len的大小进行调整。这样,相当于一个 block 内的线程可能要负责计算同一个(batch_id, head_id)(对应blockIdx.y, blockIdx.z)内部的多个from_len维度的行。blockDim.x的取值取决于to_len,即 reduce 维度的大小。以上述代码中to_len <= 128的 if 分支为例,launch kernel 时设置了blockDim.x = 64,这意味着一个 block 内的 64 个线程需要协作最多处理to_dim的 128 个元素,也就是一个线程最多处理128 / 64 = 2个元素。
在理解 kernel 线程组织方式的基础上,实现 softmax forward kernel 的流程就很清晰了:
- 加载当前 block 负责的位置对应的
attn_mask(长度为to_len)到寄存器; - 计算当前 block/thread 负责的输入地址;
- 线程循环处理负责的不同行;
- 读取当前处理行到寄存器;
- 计算 local max 然后 reduce 得到 block max(需要同时考虑
attn_mask); - 计算 exp(val - max_val) 然后求和得到 softmax 分母(sum(exp(val - max_val)));
- 计算最终的 softmax 结果,写到结果 global memory;
我们需要实现的 kernel 与已经提供的 kernel 整体流程基本一致,唯一区别是进行 block 内 reduce 时,是只需要 warp 级别同步;还是需要 block 级别同步(借助 shared memory 得到 reduce 结果)。
1.2 backward
softmax 的 backward kernel 代码已经提供,这里简要介绍下 softmax backward kernel 整体流程。
首先看 kernel launch 的 blockDim/gridDim 参数:
1 | |
可以看到,backward kernel 的 thread/block 组织方式简单很多:
- 一个 block 固定是
32 * 4共 128 线程。- 每个 warp 负责处理一行;一个 block 内 4 个 warp 处理 4 行。
- 一个 warp 内的 32 线程负责读取并计算
to_len(函数参数的softmax_len)个数值。
gridDim.x数量由总rows = batch_size * nhead * from_len除以每个 block 处理 4 行向上取整计算得到。- 在我们需要实现的根据
softmax_len大小进行 kernel launch 时,我们要计算每个线程需要读取的最大元素数量填充 backward kernel 的ITERATIONS模板参数。
kernel 内的执行流程为:
- 计算当前 warp 负责的 row_id;
- 每个线程循环读取上层梯度和原始输入到寄存器,并进行线程局部 reduce;
- 进行 warp 级别 reduce;
- 根据 softmax backward 公式计算最终梯度并写到 global memory。
2. LayerNorm
2.1 forward
LayerNorm forward kernel 的流程相对而言比较直观:
- 根据课程 slides 的方法一次遍历 + reduce 同时得到 mean/var;
- 利用 shared memory 广播 mean/var 到每个
thread(这一步不可省略,因为已有代码提供的
blockReduce结果只能让 block 的第一个 warp 得到正确 reduce 结果); - 再次遍历,根据 LayerNorm 公式计算结果并写到 global memory。
有几点要注意的是:
blockReduce若连续多次调用,中间必须加 block 级别同步保证前一次的 reduce 完成,否则可能出现竞态得到错误结果。- kernel 参数传入的
hidden_dim是真实 dim 除以 4 之后的,以方便 kernel 内部指针偏移量计算;但是在计算 mean/var 时,要除以真正的 dim。
2.2 backward
LayerNorm 的 backward 实现需要计算两个梯度:
- 输入的
(rows, hidden_dim)shape tensor 的梯度。 - LayerNorm 计算中的 weight 与 bias 的梯度,也就是下面的
dgamma_dbetakernel 完成的功能。
2.2.1 d_inp
这一 kernel 主要实现流程就是按照课程 slides 或者代码注释完成:
- 每个线程循环遍历得到 LayerNorm backward 公式中的两个 local reduce 结果;
- 调用
blockReduce并广播得到整个 block 的 reduce 结果; - 再次循环遍历,计算 backward 结果。由于向量访存优化的存在,实现时可能会有些重复代码,可以考虑抽取为单独的 device 函数以简化代码,例如:
1 | |
2.2.2 dgamma_dbeta
homework 文档已经提供了 dgamma_dbeta
的计算公式,我们只需要按照公式实现。在看 kernel
具体流程之前,我们先看下这个 kernel 的线程任务划分。
首先是 launch kernel 代码:
1 | |
其中 blockDim 为固定的
32 * 32;gridDim 则用 hidden_dim
对 32 向上取整得到。由此我们可以看出来线程任务划分如下:
- 不同 block 处理整个
[rows * hidden_dim]输入的[:, blockIdx.x * TILE_DIM: blockIdx.x * TILE_DIM + TILE_DIM部分,也就是除了整个输入所有行的部分列; - 对一个 block 来说,线程划分为了 x 和 y 两个维度,其中:
- 对于
threadIdx.y相同的一个 warp 内 32 个线程,协作读取输入的一行; threadIdx.y不同的 warp 之间负责不同 rows;
- 对于
kernel 整体流程如下:
- 线程跨步读取不同 rows,每个 thread 计算得到负责的 column_id 的多个 rows 线程局部 reduce 结果(注意边界判断);这一步完成后,不同 warp 得到了不同 rows subset 的 reduce 结果,warp 内的一个线程拥有某个 column_id 的部分 rows reduce 结果;
- 使用 shared memory 写入 local reduce 结果并转置读取;这样,一个 warp 的 32 个线程拥有了同一个 column_id 的不同 rows subset reduce 结果;
- 然后利用
cooperative_groups接口进行 warp 内 reduce,得到了某个 column_id 的全部 rows 最终 reduce 结果; - 最后,让不同 warp 内的
lane_id == 0的第一个线程,写最终 reduce 结果到结果 global memory。
3. Adopt Fused Kernels in Transformer
这一步的整体流程如下:
- 给相关 Module 构造函数增加
use_fused_kernel参数; - 修改相关 Module forward 实现,在
use_fused_kernel开启时,调用tensor_functions模块的xxxFunction.apply接口,实现 fused kernel 调用。
需要注意的是,这一步可能要从之前的 homework3 copy 部分代码,需要仔细 check 下 copy 过来的代码是完整的。
最后,附上是否开启 kernel fuse 的运行耗时记录:
1 | |
由于 softmax/LayerNorm 仅占 Transformer 计算的一小部分,所以整体性能提升有限,但是整体符合预期。
4. Conclusion
自定义 kernel fuse 流程:
- 实现 CUDA kernel;
- 实现 C++ Operator 接口;
- 接入 Python 保留接口到上层;
- 自定义 Function 实现 forward/backward 方法;
- 实现 Module forward 方法,内部调用自定义 Function
apply方法,实现 fused kernel 调用。