Fused SwiGLU Wide Packed Save-Factors 实现说明

这份文档只解释当前保留下来的 packed save-factors 方案:为什么这样算、forward/backward 怎么走、正确性如何判断、性能和显存收益来自哪里。

核心思路是:forward 用一个 wide GEMM tile 同时算出 left 和 gate,直接完成 SwiGLU epilogue,并保存 backward 所需的两个 factor;backward 不再保存或重读完整 preactivation,而是把 saved factors 原地变成 grad_de,再交给两个高性能 GEMM 消费。

原理

普通 SwiGLU Forward

SwiGLU 是 GLU 的一个变体:一次 linear projection 产出两半,一半作为 value,一半作为 gate。这里记作 left 和 gate。

x:             [M, K]
linear weight: [2N, K]      # 普通 PyTorch Linear 权重布局
packed_weight: [K, 2N]      # 内部 packed 布局
left:          [M, N]
gate:          [M, N]
out:           [M, N]
factors:       [M, 2N]
grad_out/dy:   [M, N]
grad_de:       [M, 2N]

普通实现可以写成:

preact = x @ W          # [M, 2N]
left, gate = split(preact)
out = left * silu(gate)

其中:

sigmoid(z) = 1 / (1 + exp(-z))
silu(z)    = z * sigmoid(z)
out        = left * gate * sigmoid(gate)
SwiGLU forward formula
SwiGLU forward formula
Packed SwiGLU save-factors dataflow
Packed SwiGLU save-factors dataflow

最关键的差别是:baseline 会 materialize preact=[left|gate];save-factors 方案不保存原始 preactivation,只保存 backward 公式真正需要的两个 factor。

普通 SwiGLU Backward

记 dy = dL / dout。因为 out = left * silu(gate),所以对两半 projection 输出求导:

dleft = dy * silu(gate)
dgate = dy * left * silu'(gate)

SiLU 的导数是:

s = sigmoid(gate)
silu'(gate) = s * (1 + gate * (1 - s))

于是:

dleft = dy * gate * sigmoid(gate)
dgate = dy * left * sigmoid(gate) * (1 + gate * (1 - sigmoid(gate)))

grad_de 就是 loss 对第一层 projection 输出 [left|gate] 的梯度:

grad_de = dL / d[left, gate]
        = [dleft, dgate]

它随后被两个 GEMM 消费:

grad_x = grad_de @ W.T
grad_W = x.T @ grad_de

如果权重以 packed layout 维护,上面第二个式子的输出列顺序也自然是 packed 的,不需要在 backward 里额外 unpack。

SwiGLU backward formula
SwiGLU backward formula

Save-Factors 改写

普通 backward 需要从 saved preactivation 里重新计算 sigmoid(gate)、silu(gate) 和 silu'(gate)。save-factors 方案把这一步提前到 forward 里。

forward 保存:

factor_left = silu(gate)
factor_gate = left * silu'(gate)
factors     = [factor_left | factor_gate]

backward activation 部分就退化成两个逐元素乘法:

dleft = dy * factor_left
dgate = dy * factor_gate

这也是为什么它既能减少 backward 的 activation 计算,又能把 factors 原地覆盖成 grad_de,避免再额外分配一张完整的 [M, 2N] 临时 tensor。

Save-factors rewrite
Save-factors rewrite

Packed Weight Layout

packed_weight 不是简单转置,而是把 left/gate 按 hidden chunk 交错摆放。普通布局是:

[left_all | gate_all]

packed 布局是:

[left_chunk0 | gate_chunk0 | left_chunk1 | gate_chunk1 | ...]
Packed W1 layout
Packed W1 layout

用一个小例子看,假设 hidden 一半宽度是 8,每个 chunk 宽度是 4:

N = 8
chunk width = 4

普通转置后列方向是:

weight_t columns:

| L0 | L1 | L2 | L3 | L4 | L5 | L6 | L7 | G0 | G1 | G2 | G3 | G4 | G5 | G6 | G7 |
  <----------- left_all ----------->   <----------- gate_all ----------->

pack 后列方向变成:

packed_weight columns:

| L0 | L1 | L2 | L3 | G0 | G1 | G2 | G3 | L4 | L5 | L6 | L7 | G4 | G5 | G6 | G7 |
  <--- left chunk 0 ---> <--- gate chunk 0 ---> <--- left chunk 1 ---> <--- gate chunk 1 --->

这样每个 CTA 负责一个 hidden tile 时,就能连续读到一组 [left_chunk | gate_chunk]:

B tile = packed_weight[:, current_2BN_tile]
       = [left_chunk | gate_chunk]

然后用一个 wide dot 同时算出 left 和 gate:

acc = tl.zeros((BM, 2 * BN), dtype=tl.float32)
acc = tl.dot(a_tile, b_tile, acc)

这就是 packed single-wide 比 dual-dot 更稳定的原因:一个 CTA 内部只做一次 wide GEMM 主循环,left/gate 共享同一份 x tile load 和同一个 accumulator 组织。

实现流程

Forward

CTA tile for packed wide GEMM
CTA tile for packed wide GEMM

forward 是一个 persistent/TMA 风格的 wide GEMM epilogue kernel。每个 CTA 负责一个 [M tile, N tile],但权重 tile 宽度是 2N,所以 accumulator 形状是 [BM, 2BN]。

1. 读取 x tile:              [BM, BK]
2. 读取 packed weight tile:  [BK, 2BN] = [left_chunk | gate_chunk]
3. K 维循环累加 wide GEMM:    accumulator [BM, 2BN]
4. split accumulator:         left [BM, BN], gate [BM, BN]
5. 计算 out:                 left * silu(gate)
6. 保存 factors:             [silu(gate) | left * silu'(gate)]
7. 写出 out 和 factors

forward 实际写出的 tensor 是:

out     = left * silu(gate)
factors = [factor_left | factor_gate]

这里不会写出完整原始 preactivation:

preact = [left | gate]

因此第一层 GEMM、SwiGLU activation、factor save 三件事被放进同一个 kernel;baseline 则通常会先写出 [M, 2N] preactivation,再由 activation kernel 读取它。

Backward

backward 分成一个轻量 activation-gradient kernel 和两个大 GEMM。

1. 读取 dy:       [M, N]
2. 读取 factors:  [M, 2N] = [factor_left | factor_gate]
3. 原地覆盖:
     factor_left -> dleft = dy * factor_left
     factor_gate -> dgate = dy * factor_gate
4. 得到 grad_de: [M, 2N] = [dleft | dgate]
5. GEMM 计算 grad_x
6. GEMM 计算 grad_weight

同一块 allocation 从:

factors = [factor_left | factor_gate]

变成:

grad_de = [dleft | dgate]

随后两个大 GEMM 分别是:

grad_x = grad_de @ packed_weight.t()
grad_weight = x.t() @ grad_de

这两个 GEMM 当前保留 cuBLAS/PyTorch matmul,因为实测自写 Triton reduction GEMM 没有超过 cuBLAS。

这里没有把两个 backward GEMM 强行合成一个 kernel。原因是 grad_x 和 grad_weight 的 reduction 方向不同:一个沿 hidden/output 维消费 grad_de,另一个沿 batch/token 维消费 grad_de。强行合并通常会牺牲 GEMM 主体效率,抵消少一次 kernel launch 的收益。

正确性

这个方案保存的是 BF16 factors,不是原始 preactivation,因此它和 baseline 不应该按 bitwise identical 判断。正确性口径是:forward 输出、grad_x、grad_weight 都和 save-factors 语义下的参考结果对齐,误差维持在 BF16 factor-save 的合理范围内。

小 shape 上常见误差量级:

out_rel          ~0.006
grad_x_rel       ~0.006
grad_weight_rel  ~0.003

需要注意:backward 会原地覆盖 saved factors,所以这套方案适合普通训练里的单次 backward。重复 backward 或依赖 backward 后的 saved factors,都不属于这个 fast 方案的正确语义。

另外,grad_weight 的列顺序会跟 packed weight 保持一致,因为 grad_de 的列顺序就是 packed 的 [left_chunk | gate_chunk | ...]。这意味着 backward 不需要额外 unpack;优化器可以直接维护 packed parameter。

性能

显存收益

baseline 逻辑大致是:

preact = F.linear(x, W)      # [M, 2N]
out = triton_swiglu(preact)  # [M, N]

为了 backward,autograd 需要保留 preact。backward 时还会生成:

grad_de [M, 2N]

所以峰值里会同时出现:

preact [M, 2N] + grad_de [M, 2N]

save-factors 方案只保留一张 [M, 2N]:

forward: factors [M, 2N]
backward: factors 原地覆盖成 grad_de [M, 2N]

也就是说它不是完全不存,而是把“保存 preact + 另分配 grad_de”变成 “保存 factors,然后原地变成 grad_de”。

在 B200、shape [M, K, N] = [11136, 3584, 14336] 上,最近一次观测到显存峰值少约 338 MiB。这个数字不会严格等于单个 tensor 的理论大小,因为 CUDA allocator、cuBLAS 临时 buffer、gradient buffer、reserved memory 都会影响峰值。

速度收益

速度收益主要来自三点:

1. forward 把第一层 GEMM、SwiGLU epilogue、factor save 放进一个 kernel。

baseline 是 F.linear 生成 [M, 2N],再由 Triton SwiGLU kernel 读它、算 activation、写 [M, N]。

2. backward activation 不再重算 sigmoid 和 silu'。

baseline backward 需要从 saved preact 里读 left/gate 并计算 sigmoid、 silu、silu'。save-factors 已经把两个 factor 存好了,backward activation kernel 只做两个 multiply。

3. factors 原地覆盖成 grad_de,减少 backward 峰值和 allocator 压力。

后面的 grad_x、grad_weight 仍然交给 cuBLAS,避免自写 GEMM 主体退化。

实现差异对比

阶段 Baseline Optimized 主要收益
Forward 先做 F.linear 写出 [M, 2N] preactivation,再由 activation kernel 读回并写出 [M, N]。 一个 packed wide GEMM epilogue kernel 同时完成 projection、SwiGLU 和 factor save。 少一次 preactivation HBM 往返,少一个 activation kernel 边界。
Backward activation 从 saved preactivation 重新计算 sigmoid、SiLU 和 SiLU 导数,再生成 grad_de。 读取 saved factors,两个 multiply 后原地覆盖成 grad_de。 减少 activation 计算,并复用 factors allocation。
Backward GEMM grad_x 和 grad_weight 由高性能 GEMM 完成。 保持两个大 GEMM 不变,只改变它们消费的 grad_de 生成方式。 避免自写 GEMM 退化,收益集中在可融合的 epilogue/activation 部分。
Saved tensor 保存 preact,backward 期间还会生成另一张 grad_de。 保存 factors,backward 时原地变成 grad_de。 显存峰值更低,allocator 压力更小。

实测结果

指标 Baseline Optimized 变化 收益
Forward 1.554 ms 1.514 ms -0.040 ms 1.03x
Backward 3.325 ms 3.135 ms -0.190 ms 1.06x
Full fwd+bwd 5.532 ms 5.134 ms -0.398 ms 1.08x
Peak memory +1524.5 MiB +1186.6 MiB -337.9 MiB -22.2%
SwiGLU performance comparison
Speed and memory comparison

在这个 shape 上,优化后的 full fwd+bwd 从 5.532 ms 降到 5.134 ms,约 1.08x;peak memory 从 +1524.5 MiB 降到 +1186.6 MiB,约少 338 MiB。

当前最稳的结论是:forward 做 packed wide GEMM + fused SwiGLU epilogue + save factors;backward 只融合 activation-gradient 和原地覆盖,两个大 GEMM 保持 cuBLAS。