做 LLM 训练或推理优化的人,大概率都经历过这样一幕:模型结构、数据、优化器都照着开源项目抄好了,跑起来却总觉得哪里不对劲,Loss 曲线毛毛糙糙,或者明明参数量一样,吞吐和显存却跟别人差一大截。这时候很少有人会想到,问题可能出在一个最不起眼的小零件上——大模型激活函数。激活函数看似只是网络里一个固定的非线性映射,但它在 Transformer 的 FFN 层里承担的责任、带来的显存开销、以及在低精度训练下的数值风险,远比很多人以为的重要。
这篇文章就是围绕“大模型激活函数”这件事,把我实际训练、调优、推理部署过程中积累的理解和坑一次性梳理清楚。适合正在训练自己的小模型、准备换一个 FFN 结构、或者对开源模型里层出不穷的SiLU、GELU、SwiGLU感到困惑的开发者。我不打算堆公式,而是把每个选择背后的“为什么”讲透,再给可以直接抄走的实现和排查经验。
1. 先说结论:激活函数在大模型里到底是干嘛的
1.1 激活函数只活在两个地方
很多人一听到“激活函数”,首先想到的是卷积网络里的 ReLU。但在大模型里,激活函数的存在感要集中得多,基本只出现在两个位置:一个是FFN(前馈网络)中间的隐藏层,另一个是注意力里的 softmax。softmax 因为性质特殊,通常被单独讨论;真正意义上决定大模型训练行为和推理成本的激活函数,几乎全部集中在 FFN 内部。
为什么?因为 Transformer 里 Attention 负责的是“把信息从别的位置搬过来”,而真正对每个 token 做非线性变换、把特征空间旋转和拉伸的,是 FFN。一个标准的 FFN 长这样:h = Activation(x · W1) · W2,输入先升维到 3-4 倍宽度,过一遍非线性,再降维回来。大模型里 60% 到 70% 的参数都堆在这个模块里,每次前向传播的 FLOPs 大头也在这里。所以激活函数选得怎么样,直接决定了这个模型能不能训得动、训得稳、跑得快。
1.2 一个“固定小零件”为什么影响全局
如果我们把整个网络里的非线性全部抽掉,那不管堆多少层线性层,最终都等价于一次线性变换,模型表达能力瞬间塌掉。激活函数就是那个“不允许网络塌成一张纸”的结构件。
但在大模型这个规模上,它的影响远不止“提供非线性”这么简单。我实际对比过几组实验,同一个 1B 参数的模型,只把 FFN 的激活从 GELU 换成 SwiGLU,最终 loss 能低一截,但训练吞吐也掉了;换成某些带饱和区的激活,前期收敛倒是快,后期 loss 就是压不下去。这说明激活函数至少同时在四个层面起作用:
- 梯度行为:负半轴的斜率、饱和区的梯度衰减速度,决定了优化器在深层网络里能不能把信息传回去;
- 输出分布:激活后的值域、稀疏程度、是否容易出现离群大值,直接影响后续层的数值范围和归一化稳定性;
- 硬件效率:
exp、tanh、erf这些超越函数在 GPU 上的代价不同,低精度下的实现方式也不一样; - 显存占用:反向传播时要保存激活值,FFN 中间张量是大头,激活函数类型直接决定这部分显存大小。
所以别再把激活函数当成“模型里随便填一个就行”的参数了。它就是那种平时不吭声、一换就出事的零件。
2. 从 ReLU 到 GELU:前 LLM 时代的两次关键迭代
2.1 ReLU 的贡献和它的暗面
ReLU,也就是max(0, x),是整个深度学习兴起的最强功臣。它的优势极其朴素:计算只要一次比较,梯度在正半轴恒为 1,不存在 sigmoid/tanh 那种两边饱和导致梯度消失的问题。所以 2012 年之后的视觉模型、早期的序列模型,基本都被 ReLU 统治。
但 ReLU 有一个臭名昭著的毛病:神经元死亡(dying ReLU)。如果一个神经元的输入长期落在负半轴,它的梯度就是 0,权重再也不更新,这个神经元就永久“死”掉了。在小模型里这个问题还能靠调学习率、加初始化技巧硬扛,但在动辄几千亿参数的大模型里,死一片神经元等于浪费一大块模型容量,这是不可接受的。
更微妙的问题是 ReLU 在零点处“硬转折”。这个不可导点让 loss 曲面变得很锋利,优化器在后期需要非常小心地控制步长。用我自己的话说,ReLU 像一个脾气很冲的开关,要么全开要么全关,训大模型时这种性格不太讨喜。
2.2 GELU 的数学原理与工程近似
GELU(Gaussian Error Linear Unit)的出发点很有意思:它不是硬性把负值截断,而是给每个输入乘上一个 0 到 1 之间的“概率门”。公式长这样:
GELU(x) = x · Φ(x)其中 Φ(x) 是标准正态分布的累积分布函数。x 越大,门越接近 1;x 越小,门越接近 0。它保留了 ReLU 的“正半轴近似线性”特性,但负半轴不是完全死寂,而是有一个平滑的、微小的负输出,意味着梯度永远有机会流动。
问题在于 Φ(x) 涉及误差函数 erf,在 GPU 上直接算不太划算。所以工程上绝大多数实现用的是近似公式:
GELU(x) ≈ 0.5 · x · (1 + tanh(√(2/π) · (x + 0.044715 · x³)))这个近似误差极小,在 fp16 精度下完全够用。还有一个更简洁的近似:x · sigmoid(1.702x),误差稍微大一点,但胜在只依赖 sigmoid,某些硬件加速库会这么用。
2.3 为什么 BERT、GPT-2 时代选择了 GELU
回头看历史,BERT 系列和 GPT-2 系列都把 FFN 的激活从 ReLU 换成了 GELU,这个选择不是拍脑袋。我在自己的实验里观察到几个实际原因:
第一,GELU 更光滑。光滑的非线性让 loss 地形更平缓,同样学习率下训练曲线更稳定,不容易出现 loss 突然尖刺的情况。第二,负半轴小梯度提供了“软纠错”能力。ReLU 一旦把神经元死掉就是永久性的,GELU 则永远留了一条缝,等特征分布变化后这个神经元还能“活过来”。第三,GELU 可以理解成一种确定性的 dropout:它按输入的相对大小对神经元做软性保留,这在 Transformer 这种对正则化敏感的结构里,副作用比随机 dropout 更可控。
那个时代还有一个细节:BERT 的 FFN 中间维度是 3072,是隐层 768 的四倍。4 倍扩展是经验值,因为激活函数的非线性会压缩信息密度,宽度不够就无法充分发挥升维再降维的“记忆回忆”能力。到今天,大部分不是 GLU 系的模型依然沿用 4 倍这个比例。
3. SwiGLU 是如何接管大模型 FFN 的
3.1 GLU 门控:从卷积到 Transformer 的跨界
GLU(Gated Linear Unit)最早是 2017 年在一篇关于卷积序列建模的论文里提出来的。它的核心思想是给线性变换的输出加一个“门控通道”,让网络自己学习什么信息该放行、什么信息该抑制。形式如下:
GLU(x) = σ(W1 · x) ⊙ (W2 · x)σ 是 sigmoid,⊙ 是逐元素相乘。左边那个σ(W1·x)就是“门”,右边W2·x是“值”,两者逐元素相乘得到最终输出。你可以把它想象成一个水龙头:门决定流量,值决定水的内容。相比单纯的非线性激活,这种乘法交互让 FFN 的表达能力更强,因为它允许不同维度之间互相调制,而不是每个维度独立做标量变换。
这套机制后来被证明和 Transformer 的 FFN 是绝配。原因也不难理解:Attention 已经在做“选择重要 token”这件事,FFN 如果能做“选择重要特征”,整个模型的建模粒度就更加统一。
3.2 SwiGLU 的数学形式和直觉
SwiGLU 就是把 GLU 里的 sigmoid 门换成了 Swish 门。Swish 也叫 SiLU,形式是x · sigmoid(x),它和 GELU 一样光滑,但计算更简单,而且在负半轴存在一个“先降后升”的凹陷,这个凹陷让 Swish 具备一定的正则化效果。于是:
SwiGLU(x) = (Swish(W1 · x)) ⊙ (W3 · x) = (W1 · x) · σ(W1 · x) ⊙ (W3 · x)注意这里用了两个权重矩阵 W1 和 W3,它们都从同一个输入 x 投影出来,一个做门,一个做值。外面再接一个 W2 把结果投影回原始维度。实际写出来就是:
FFN(x) = W2 · ( Swish(x · W1) ⊙ (x · W3) )直觉上,Swish 门比 sigmoid 门“温柔”。sigmoid 在输入绝对值较大时容易饱和,梯度直接归零;Swish 在正半轴没有饱和区,门值可以大于 1,梯度能一直传下去。这让 SwiGLU 在深层网络里比原始 GLU 更好训。
顺便说一句,把 Swish 换成 GELU 就是 GeGLU,换成 ReLU 就是 ReGLU,它们在很多数据集上效果非常接近。实践中之所以 SwiGLU 最流行,很大程度上是因为 Swish 实现最简单、GPU 上算得快、低精度下数值最稳。
3.3 参数量的账:2 个线性层变 3 个
这是换激活函数时最容易忽略的坑。标准 FFN 只需要两个权重矩阵:升维矩阵 W1(形状d → d_ff)和降维矩阵 W2(形状d_ff → d)。而 SwiGLU 需要三个矩阵:门矩阵、值矩阵、降维矩阵,参数直接变成 1.5 倍。
那为什么主流模型还愿意用它?因为门控带来的效果提升值得这个代价,而且可以通过缩小中间维度把参数量拉回原来的水平。具体算法是这样的:标准 FFN 通常取d_ff = 4d,参数量是2 × d × 4d = 8d²。SwiGLU 如果中间维度取d_ff' = (8/3)d,参数量就是3 × d × (8/3)d = 8d²,和标准 FFN 完全持平。
举一个实际例子:某个 7B 级模型,隐层维度 4096,标准 FFN 中间维度应该是 16384;但它用了 SwiGLU 后中间维度取 11008,恰好就是 4096 的 2.69 倍,非常接近 8/3。这说明模型设计者在选维度时是算过这笔账的。FLOPs 方面也可以验证:每条 token 在标准 FFN 里大概是16d²次浮点运算,SwiGLU 配 8/3 中间维度也是16d²,所以“性价比”是持平的。
3.4 主流模型怎么用 SwiGLU
现在开源生态里,从 LLaMA 系列到之后大量追随者,FFN 几乎被 SwiGLU 统一了。PaLM 用的也是 SwiGLU,Mistral、Qwen 这些后续模型基本都沿用了这个结构,只是中间维度的具体数值稍有出入。再往前看,GPT-3 那一代用的还是 GELU,所以“GELU 时代”和“SwiGLU 时代”的分界线其实非常清晰:大概就是 2022 年前后。
还有一个值得留意的趋势:最近有一些工作尝试用ReLU²(也就是(ReLU(x))²)替代 SwiGLU,因为它在推理时不需要计算 exp,可以把 FFN 里的门控矩阵权重融合掉,减少一半的矩阵乘,对推理吞吐非常友好。我实测过类似方案,在 1B 左右规模下 loss 差距很小,所以“SwiGLU 永远最优”这种话别急着下,硬件约束会在某个阶段重新改变选择。
4. 工程实现:手写 SwiGLU 与数值稳定性
4.1 PyTorch 里的两种写法
SwiGLU 的实现本身不复杂,但工程上有两种风格,我建议你根据需求二选一。第一种是“三矩阵分开写”,结构清楚,适合做研究和调试:
import torch import torch.nn.functional as F from torch import nn class SwiGLUFFN(nn.Module): def __init__(self, hidden: int, intermediate: int): super().__init__() self.gate_proj = nn.Linear(hidden, intermediate, bias=False) self.up_proj = nn.Linear(hidden, intermediate, bias=False) self.down_proj = nn.Linear(intermediate, hidden, bias=False) def forward(self, x): gate = self.gate_proj(x) up = self.up_proj(x) return self.down_proj(F.silu(gate) * up)第二种是“门和值合并成一个矩阵”,也就是把 gate 和 up 的权重按最后一维拼接成一个2 * intermediate的大矩阵,一次 GEMM 出两个结果,然后chunk拆开:
class FusedSwiGLUFFN(nn.Module): def __init__(self, hidden: int, intermediate: int): super().__init__() self.gate_up_proj = nn.Linear(hidden, 2 * intermediate, bias=False) self.down_proj = nn.Linear(intermediate, hidden, bias=False) def forward(self, x): gate_up = self.gate_up_proj(x) # [B, T, 2 * intermediate] gate, up = gate_up.chunk(2, dim=-1) return self.down_proj(F.silu(gate) * up)两种写法数学上完全等价,实际效果也一致。第二种的好处是少发一次 GEMM kernel,在 Tensor Parallel 场景下也更方便——gate_up_proj做成列并行,down_proj做成行并行,中间只要一次 all-reduce。很多开源推理框架内部也是这种融合布局。
4.2 fp16 下的精度陷阱
如果你用 fp16 训练,SwiGLU 比 GELU 更容易踩数值坑,原因出在 sigmoid 上。fp16 的最大表示范围只有 65504,一旦x · W1出来的值超过这个范围,sigmoid 算出来就是 inf,反向传播直接变 NaN。
更隐蔽的问题是精度损失。sigmoid 在输入大于 8 左右之后,输出就已经非常接近 1 了,而 fp16 在接近 1 的地方精度很稀碎,导致门控值本身误差很大。我在一次 3B 模型的训练里遇到过 loss 周期性上跳,最后定位到就是 gate 的 sigmoid 部分在 fp16 下精度不够,等于给每个神经元加了一个随机抖动源。
解决办法有两个:要么用 bf16——它的指数范围和 fp32 一样大,基本不会溢出,代价是尾数精度低一些;要么在算 sigmoid 时把 gate 临时 upcast 到 fp32,算完再降回原精度。第二个方案几乎没有额外成本,因为 sigmoid 不是 GEMM,不涉及大规模访存,一个 kernel 里顺手就做了。如果你自己写 cuda kernel,请务必把 sigmoid 这一段用__expf配合 float 计算。
4.3 激活值显存与重计算策略
显存是训练大模型最硬的约束之一,而 FFN 的激活值恰恰是显存大户。Flash Attention 已经把 Attention 部分从O(T²)降到O(T),但 FFN 的中间激活是O(T · d_ff),躲不掉。
我算过一笔账:假设隐层 4096、中间维度 11008、序列长度 2048、fp16 存储。单个中间张量(gate 或 up)就是2048 × 11008 × 2 字节 ≈ 45MB。反向传播时至少需要保留 gate、up 和输入 x 三份,一层就是45MB + 45MB + 17MB ≈ 107MB。一个 32 层的 7B 模型,光 FFN 激活就是3.4GB以上。这还没有算上 Attention 的中间值和其他开销。
所以现在主流训练框架都会开激活重计算(activation checkpointing):前向时不保存中间张量,只保存输入,反向时再重算一次。代价是大约多 30% 到 40% 的前向计算,换来的显存收益却非常可观——上面那个例子可以直接从 3.4GB 砍到 0.5GB 量级。如果你的模型在某个 batch size 下刚好爆显存,先别急着调结构,检查一下 FFN 中间激活是不是被完整保存了,这通常比换激活函数更立竿见影。
4.4 推理与量化场景下的激活值异常
SwiGLU 在量化推理里比 GELU 更麻烦一点。原因也很直白:silu(gate) * up这个逐元素乘法产生的中间值,分布范围比单一非线性激活要宽,而且容易出现个别维度上的离群大值。做 INT8 或 INT4 激活量化时,这些离群值会拉高整体缩放系数,导致其他本来精度足够的维度白白损失精度。
业界处理这类问题的主流思路有两种:一种是在量化前调整权重和激活的分布,把离群值“熨平”;另一种是保留部分敏感维度用高精度计算,也就是混合精度切分。日志里如果看到某些权重矩阵的量化误差明显大于其他层,优先检查它是不是门控矩阵——门矩阵对精度最敏感,因为它直接决定了信息能不能通过。我个人在做 7B 模型 INT8 推理时,把gate_proj单独留在 fp16 精度,整体困惑度损失从 0.3 降到了 0.05 以下,代价只是多占用一点点显存。
5. 常见问题排查与经验速查
5.1 换了激活函数,loss 不收敛怎么办
最常踩的坑是:把预训练权重里的 GELU 直接替换成 SwiGLU,然后拿原权重初始化,结果 loss 直接放飞。这几乎必然发生,因为两种激活的输出分布完全不同,后续层的输入范围全变了,原权重里的“经验”全部失效。
正确的做法是,要么从头训练,要么做一小段 warmup 让模型适应新激活。如果是在自己的模型上从头训练,换成 SwiGLU 后建议把学习率调低 20% 到 30%。原因不复杂:门控的乘法效应相当于放大了梯度的方差,同样学习率下更新步长会更激进,尤其是前几个 step,容易把权重推到不稳定区域。我试过保持学习率不变直接换 SwiGLU,1B 模型在 2000 步时 loss 仍然比同配置 GELU 高,调低学习率之后反而更快追平并反超。
5.2 训练中出现 NaN 或 Inf
出现 NaN 先别急着怀疑学习率,先查激活函数。经验上,fp16 训练里 SwiGLU 的 NaN 绝大多数来自 sigmoid 溢出。最简单的定位方法是在 FFN 前向里加几个断言:检查gate的绝对最大值是否超过 60000,或者silu(gate) * up是否有 inf。如果确认是 sigmoid 溢出,要么换 bf16,要么在 sigmoid 前做clamp。注意clamp不能太激进,否则会影响门控精度,我的建议是只限制极端值:gate = gate.clamp(-100, 100)这种量级,基本不会影响正常训练。
还有一类 NaN 来自 GELU 的 tanh 近似在 fp16 下的中间变量溢出:x³这个项在 x 较大的时候数值增长很快。所以如果某个老模型用的是 GELU 且经常出 NaN,优先把 tanh 近似换成内置的F.gelu(PyTorch 内部有更稳定的实现),或者同样用 bf16。
5.3 激活函数与初始化、归一化的关系
激活函数不是孤立的,它和归一化层、初始化策略绑定在一起。现代大模型基本都是 Pre-Norm 结构,FFN 的输入已经过了一层 RMSNorm 或 LayerNorm,所以输入的范围相对可控。但 SwiGLU 的门控分支会放大方差,如果你用标准nn.Linear默认初始化,第一轮前向时 gate 的值域可能偏大,导致 sigmoid 一开始就进入饱和区。
我的做法是给三个 FFN 线性层的初始化标准差乘个 0.7 左右的系数,或者干脆用nn.init.normal_(weight, mean=0.0, std=0.02)这种更小的标准差起步。后面让优化器自己调。比起花里胡哨的初始化方案,这招朴素但有效。
5.4 激活函数选型速查表
| 激活函数 | 核心公式 | 大模型中的用途 | 参数量倍数 | 数值风险 |
|---|---|---|---|---|
| ReLU | max(0, x) | 少见于现代 LLM,早期基线 | 2 个权重矩阵 | 神经元死亡 |
| GELU | x·Φ(x) | GPT-3 时代主流,BERT 系 | 2 个权重矩阵 | fp16 下 tanh 近似溢出 |
| Swish/SiLU | x·σ(x) | 单独使用较少,多用于门控 | 2 个权重矩阵 | 无重大风险 |
| SwiGLU | Swish(W1x) ⊙ W3x | 当前开源 LLM 主流 | 3 个权重矩阵 | sigmoid 溢出,量化困难 |
| GeGLU | GELU(W1x) ⊙ W3x | SwiGLU 的变体 | 3 个权重矩阵 | 同上 |
| ReLU² | (ReLU(x))² | 推理优化方向的新选择 | 可融合为 2 个 | 低精度友好 |
一句话总结选型逻辑:从零训练新模型,预算和显存都够,优先 SwiGLU;做极致的推理吞吐优化,可以认真评估 ReLU²;做老模型微调,别动它的激活函数,老老实实用原结构。
6. 一些实际踩坑的个人体会
最后分享几个我在真实项目里沉淀下来的经验。第一,激活函数决定的是训练的天花板,不是地板。换成更强的激活,前期可能更痛苦,但后期能做到的 loss 下限往往更低。所谓“更强的激活”,核心抓手是梯度流动性和乘法交互,不是公式看起来复不复杂。第二,不要因为某个激活在 7B 上效果好,就默认 1B 也会好。小模型对表达能力不敏感,但对数值稳定性和优化难度更敏感,有时候 GELU 反而比 SwiGLU 省心。我的习惯是先在 100M 到 300M 的小模型上把“激活函数 + 学习率 + 归一化”这个组合验证一遍,再放大到目标规模。
第三,也是最想提醒的一点:改激活函数从来不是改一行公式的事。它像多米诺骨牌一样牵动参数量、显存策略、混合精度方案、量化误差分布,甚至连 Tensor Parallel 的切分方式都要跟着调。如果你只是想在别人的模型上小改一下,我建议先跑一个 3 天的消融实验看看收益,别拿一个月的训练预算去赌直觉。激活函数作为大模型里最“沉默”的组件,值得被认真对待——毕竟模型能不能训到底,很多时候就藏在这一次次平凡的映射里。