Skip to content

第五章 Dao 前向 CUDA 实现

从重叠窗口看到可复用数据

假设一个任务负责时间区间 $[t_0,t_0+T-1]$。前向输入范围是:

$$ [t_0-(K-1),\ t_0+T-1]. $$

其中左侧多出来的 $K-1$ 个值称为 halo。若每个输出独立从全局内存读取 $K$ 个元素,共需约 $TK$ 次读取;先搬入 $T+K-1$ 个元素,再在片上复用,可以显著减少重复访问。

halo 重复加载的相对开销约为 $(K-1)/T$。增大 $T$ 会减少该比例,但也增加片上容量、寄存器和任务粒度。块越大不一定越快:并行任务数不足或资源占用过高,都可能抵消复用收益。

以下实现细节均对应 v1.5.0 前向 CUDA 源码。

时间连续路径的任务划分

causal_conv1d_fwd_kernel 的 grid 为 (B,C),一个 block 负责一个 batch 的一个通道,并在 block 内循环处理该通道的时间 chunks。该版本启动 128 个线程;每线程处理 FP32 的 4 个元素或 FP16/BF16 的 8 个元素,对应每线程 16 字节。

所以一个 chunk 分别覆盖 512 或 1024 个时间位置。这里时间 chunk 不对应独立 block;它是同一 block 内的循环迭代。若 $BC$ 很小而 $L$ 很长,block 总数仍由 $BC$ 决定,这是理解该路径并行度的关键。

权重很短,先加载为每线程的 FP32 weight_vals,随后循环复用。输入通过 CUB 的 block load/store 组织;能走向量化时使用向量路径,否则使用带有效长度的路径处理尾部。

相邻线程与相邻 chunk 怎样交换历史

一个线程持有自己的若干连续时间元素,但它负责的第一个输出还需要前一线程末尾的数据。源码使用 smem_exchange 交换这些值。第一个线程需要的历史来自上一 chunk 的末尾;最开始将其初始化为零。

关键时序是:其他线程先发布当前数据,线程 0 读取上一 chunk 遗留的尾部,然后最后一个线程才能覆盖共享槽位,把当前 chunk 的尾部留给下一轮。相关同步不是装饰;过早覆盖会让 chunk 边界读取错误时间的数据。

获得“历史片段 + 当前片段”后,每个输出在寄存器中执行:

$$ z_i=\beta+\sum_{k=0}^{K-1}w_k x_{i-K+1+k}. $$

因为 $K$ 是模板常量,短循环可展开。FP32 累加完成后可直接计算 SiLU,最后转为输出 dtype 写回。融合省掉了单独的预激活输出与激活输入之间的一次大张量往返。

通道连续路径为什么要换组织

对 channel-last,连续地址对应不同通道。如果仍让相邻线程都沿某一个通道的时间前进,访存会跨越较大的时间 stride。Dao 因此先按适合合并访存的方式搬运时间×通道 tile,再在共享内存中以适合计算的方式读取。

该版本前向取 $T=64$。通道 tile 宽度为 $128/\operatorname{sizeof}(input)$,FP32 是 32 个通道,FP16/BF16 是 64 个通道。grid 为:

$$ \left(B,\left\lceil L/64\right\rceil, \left\lceil C/C_{tile}\right\rceil\right). $$

这条路径沿时间也产生独立 block。每个 block 读取自己的左 halo,因此不需要等待前一个 block。

先搬运 再改变计算访问方向

加载阶段每线程取 16 字节,一组 8 个线程覆盖一行 128 字节。数据写入共享内存:

$$ x_{smem}[K-1+T][C_{tile}+kNElts]. $$

前 $K-1$ 行保存 halo,随后是本块输入。额外的通道 padding 改变共享内存行跨度,是布局设计的一部分;具体冲突和收益应结合硬件分析,而不能把“多加若干列”机械套到 MLU 上。

加载完成后,线程重新按通道与时间小段分工:每个线程拿某通道的一段时间及其历史,在寄存器中连续计算多个输出。算完再写回共享内存,最后恢复适合连续通道访问的方式写到全局内存。

可将其理解为四步:

全局内存按通道连续读
  → 共享内存保存 tile 和左 halo
  → 按单通道时间小段计算
  → 共享内存重排后按通道连续写

“搬运时线程怎样分工”和“计算时线程怎样分工”可以不同。这是比某个 CUDA 线程数更值得迁移的思路。

三类边界分别处理

序列开头的 halo 从零或 initial_states 获取;中间 tile 的 halo 读取真实历史输入;最后不足一个 tile 的时间或通道区域通过边界条件处理。它们不是同一种 padding。

使用 seq_idx 时,每个窗口项比较源位置与目标位置的序列标识,不同标识的贡献取零。它阻止 packed 数据中前一条样本的结尾污染后一条样本开头。必须确保标识编码确实区分窗口内的不同片段。

最终状态由最后一个时间 tile 写出。该 tile 已经包含自己的左 halo,所以即便当前序列很短,也有机会从历史与当前输入的组合中提取尾部 $K-1$ 个值。最终状态保存的是卷积输入,不是 SiLU 输出。

两条路径的对照

设计点 时间连续 通道连续
block 所有权 一个 batch、一个通道 一个 batch、一个时间 tile、一个通道 tile
时间推进 block 内循环 时间 tile 之间独立
halo 来源 线程交换及前一 chunk 遗留 显式读左侧输入或初始状态
连续搬运方向 时间 通道
片上主要用途 交换邻接片段及 CUB IO tile 存储与访问方向转换

不要只看两条路径的乘加表达式相同。真正影响性能的是数据地址、任务数量、共享内存访问和同步方式。

下一章:Dao 反向 CUDA 实现。