Sigmoid 函数
Sigmoid 函数是一种 logistic 函数,它将任意值转换到 $(0,1)$ 之间,函数表达式为:
$$ \sigma(x)=\frac{1}{1+e^{-x}}. $$
Sigmoid 函数的优点包括:
- 输出位于 $(0,1)$ 之间,输出范围有限,优化相对稳定,可以用作输出层;
- 它是连续函数,便于求导。
它的缺点也很明显:
- 饱和性。 从曲线可以看出,两侧的导数逐渐趋近于 0,容易造成梯度消失;
- 激活函数的偏移现象。 Sigmoid 的输出值均大于 0,使得输出不是零均值。后一层神经元会接收到上一层非零均值的信号,这会对梯度产生影响;
- 计算复杂度较高。 Sigmoid 中包含指数运算。
Softmax 函数
Softmax 是二分类函数 Sigmoid 在多分类上的推广,目的是将多分类结果以概率的形式展现出来。它的数学公式为:
$$ \operatorname{softmax}(x_i) =\frac{e^{x_i}}{\sum_{j=1}^{N}e^{x_j}}. $$例如,原始输出为 $[3,1,-3]$,经过 Softmax 后会被映射为 $(0,1)$ 之间的值,并且所有值之和为 1:
$$ \begin{aligned} p_1&=\frac{e^3}{e^3+e^1+e^{-3}}\approx 0.88,\\ p_2&=\frac{e^1}{e^3+e^1+e^{-3}}\approx 0.12,\\ p_3&=\frac{e^{-3}}{e^3+e^1+e^{-3}}\approx 0. \end{aligned} $$因此,可以把这些输出理解为概率。在最后选择输出节点时,可以选取概率最大的节点,也就是原始值最大的节点,作为预测目标。
Softmax 先通过指数函数拉大输入向量中元素之间的差异,然后将结果归一化为概率分布。应用到分类问题时,各类别之间的概率差异会更加显著,最大值产生的概率更接近 1,输出分布的形式也更接近目标分布。
如何理解 Softmax
可以从三个不同角度理解 Softmax。不同的视角能帮助我们更深入地理解它的应用场景。
作为 argmax 的平滑近似
Softmax 可以看作 argmax 的一种平滑近似。argmax 会直接选出最大值,并产生 one-hot 形式的结果;Softmax 则对这种输出做了一定平滑,把 one-hot 输出中最大位置对应的 1,按照输入元素的大小分配到其他位置。
所谓 argmax,就是直接返回输入参数中最大值的索引。例如:
$$ x=[2,7,4],\qquad \max(x)=7,\qquad \operatorname{argmax}(x)=1. $$作为类别概率分布
Softmax 将输入向量归一化并映射为类别概率分布。这也是深度学习中经常把 Softmax 作为 MLP 最后一层,并配合交叉熵损失函数使用的原因。交叉熵可以衡量两个分布之间的差异。
从概率图模型理解
从概率图模型的角度看,Softmax 的形式可以理解为概率无向图上的联合概率。条件最大熵模型与 Softmax 回归模型在形式上是一致的。概率图模型在很大程度上借用了一些热力学系统的理论,因此也可以从物理系统的角度理解 Softmax。
在推理阶段,Softmax 的输出通常用于:
- 采样: 根据概率分布随机采样下一个 token;
- 贪婪解码: 选择概率最高的 token,也就是执行 argmax。
如何优化 Softmax
1. 数值稳定性优化
Softmax 定义为:
$$ \operatorname{softmax}(x_i) =\frac{e^{x_i}}{\sum_{j=1}^{N}e^{x_j}}. $$直接计算可能发生溢出。例如在 FP16 中,较大的 $x_i$ 做指数运算很容易得到 inf。
假设输入为:
$$ x=[12,11,10]. $$数学上:
$$ e^{12}\approx 162754.79. $$但 FP16 能表示的最大有限值约为:
$$ 65504. $$所以在 FP16 中,直接计算 $e^{12}$ 会溢出为 inf。后续可能出现 Inf/Inf,结果就是 NaN。
减去最大值
Softmax 有一个重要性质:所有输入同时减去同一个数,结果不变。对于任意常数 $c$:
$$ \frac{e^{x_i-c}}{\sum_j e^{x_j-c}} =\frac{e^{x_i}/e^c}{\sum_j e^{x_j}/e^c} =\frac{e^{x_i}}{\sum_j e^{x_j}}. $$因此,取:
$$ m=\max(x)=12. $$计算:
$$ x-m=[0,-1,-2], $$于是:
$$ e^{x-m}=[1,e^{-1},e^{-2}]. $$所有指数结果都小于等于 1,不再发生上溢。最终:
$$ \operatorname{softmax}(x) =\frac{[1,e^{-1},e^{-2}]}{1+e^{-1}+e^{-2}} \approx[0.6652,0.2447,0.0900]. $$这就是“减去最大值”的数值稳定 Softmax。最大的指数输入变成 0,所有指数结果都落在 $(0,1]$ 范围内。
工程上一般还会:
- 输入、输出使用 FP16/BF16;
- 最大值归约和求和使用 FP32;
- 计算交叉熵时使用
log_softmax,避免先计算 Softmax 再取对数; - 对全 Mask 行进行特殊处理,防止出现 $-\infty-(-\infty)$ 导致
NaN。
工程计算中的数值类型优化
典型计算过程可以理解为:
# logits 原本是 FP16/BF16
x_fp32 = logits.float()
max_value = x_fp32.max(dim=-1, keepdim=True).values
exp_value = torch.exp(x_fp32 - max_value)
sum_value = exp_value.sum(dim=-1, keepdim=True)
prob_fp32 = exp_value / sum_value
# 最终按需要转回 FP16/BF16
prob = prob_fp32.to(logits.dtype)
也就是:
FP16/BF16 输入
↓
转换成 FP32
↓
减最大值、exp、求和、除法
↓
得到 FP32 Softmax
↓
根据需要转回 FP16/BF16
为什么不全程使用 FP32?因为 FP16/BF16:
- 占用显存更少;
- 显存带宽压力更小;
- 矩阵乘法速度通常更高;
- 更适合 Tensor Core;
- 模型权重和激活通常本来就是低精度。
为什么中间过程又要使用 FP32?因为 FP32 的数值范围和有效精度更高,更适合指数和累加操作。
log_softmax
Softmax 之后经常需要取对数,例如交叉熵:
$$ L=-\log p_y, $$其中 $y$ 是正确类别。最直接的写法是:
prob = softmax(logits)
log_prob = torch.log(prob)
loss = -log_prob[target]
问题是,某个概率可能非常小,并在低精度下溢为 0。例如:
$$ x=[0,-100]. $$理论上,第二个类别的概率约为:
$$ e^{-100}\approx 3.72\times 10^{-44}. $$如果先计算 Softmax,这个概率很可能变成 0,然后:
$$ \log(0)=-\infty, $$损失也会变成无穷大。但理论上的对数概率其实约为 $-100$,而不是负无穷。
log_softmax 直接计算对数概率。根据:
两边取对数:
$$ \log p_i=x_i-\log\left(\sum_j e^{x_j}\right). $$进一步使用稳定的 logsumexp。令 $m=\max_j x_j$,则:
$$ \log p_i =x_i-\left[m+\log\left(\sum_j e^{x_j-m}\right)\right]. $$这样不需要先得到一个极小的概率再取对数。对于 $x=[0,-100]$,log_softmax 可以直接得到近似结果:
全 Mask 行处理
在 Attention 中,不允许关注的位置会被 Mask,常见做法是将对应分数设成 $-\infty$。例如某一行:
$$ [2.1,-\infty,0.7,-\infty]. $$Softmax 后,被 Mask 的位置概率为 0:
$$ [p_1,0,p_3,0]. $$所谓“全 Mask 行”,是指这一整行没有任何有效位置:
$$ [-\infty,-\infty,-\infty,-\infty]. $$为什么全 Mask 会产生 NaN?稳定 Softmax 的第一步是求最大值:
接下来减去最大值:
$$ x_i-m=-\infty-(-\infty). $$在浮点运算中:
$$ -\infty-(-\infty)=\mathrm{NaN}, $$于是 $e^{\mathrm{NaN}}=\mathrm{NaN}$,后面整行都会变成 NaN。一旦 Attention 中出现 NaN,后续矩阵乘法、残差连接和网络层都可能被污染。
这主要是数值稳定性优化,不一定会直接提升性能。
常见处理方式是先判断这一行是否至少有一个有效元素:
valid_row = mask.any(dim=-1, keepdim=True)
masked_logits = torch.where(
mask,
logits.float(),
float("-inf"),
)
# 对于全 Mask 行,暂时将最大值设为 0,防止 -inf - (-inf)
row_max = masked_logits.max(dim=-1, keepdim=True).values
row_max = torch.where(valid_row, row_max, 0.0)
exp_value = torch.where(
mask,
torch.exp(masked_logits - row_max),
0.0,
)
denominator = exp_value.sum(dim=-1, keepdim=True)
safe_denominator = torch.where(valid_row, denominator, 1.0)
prob = exp_value / safe_denominator
prob = torch.where(valid_row, prob, 0.0)
对于全 Mask 行,最终将它定义为:
$$ [0,0,0,0]. $$这样后续计算 $PV$ 时,得到的 Attention 输出也是零向量,不会产生 NaN。
2. 融合算子,减少显存读写
朴素 Softmax 可能被拆成多个 Kernel:
- 求每一行的最大值;
- 计算 $\exp(x-\max(x))$;
- 求和;
- 除以总和。
如果每一步都把中间结果写回显存,就会产生大量 HBM 读写和 Kernel Launch。更好的实现会把这些步骤融合成一个或少数几个 Kernel:
load logits
→ reduce max
→ exp(logits - max)
→ reduce sum
→ normalize
→ store result
Attention 中还可以进一步融合:
scale
→ add mask
→ softmax
→ dropout
常见形式包括:
scale + mask + softmax;masked_softmax;softmax + dropout;- $QK^\mathsf{T}+\text{scale}+\text{mask}+\text{softmax}+PV$。
Softmax 的算术量其实不大,很多时候真正的瓶颈是显存带宽和 Kernel 启动开销,因此算子融合往往比减少几次浮点运算更重要。
3. Online Softmax
标准稳定 Softmax 需要先求最大值,再求指数和,逻辑上至少需要两次扫描。Online Softmax 可以在一次流式扫描中同时更新最大值和归一化分母。
假设已经处理了前 $j-1$ 个元素:
$$ \begin{aligned} m_{j-1}&=\max(x_1,\ldots,x_{j-1}),\\ \ell_{j-1}&=\sum_{i=1}^{j-1}e^{x_i-m_{j-1}}. \end{aligned} $$读入新的 $x_j$ 后:
$$ \begin{aligned} m_j&=\max(m_{j-1},x_j),\\ \ell_j&=\ell_{j-1}e^{m_{j-1}-m_j}+e^{x_j-m_j}. \end{aligned} $$下面看整个公式是如何推导出来的。原始公式为:
$$ \ell_j=\sum_{i=1}^{j}e^{x_i-m_j}. $$把新元素单独拿出来:
$$ \ell_j=\sum_{i=1}^{j-1}e^{x_i-m_j}+e^{x_j-m_j}. $$对于前面的历史元素,可以写成:
$$ x_i-m_j=(x_i-m_{j-1})+(m_{j-1}-m_j). $$所以:
$$ e^{x_i-m_j} =e^{x_i-m_{j-1}}e^{m_{j-1}-m_j}. $$代入原式:
$$ \ell_j =\sum_{i=1}^{j-1}e^{x_i-m_{j-1}}e^{m_{j-1}-m_j} +e^{x_j-m_j}. $$其中 $e^{m_{j-1}-m_j}$ 对所有历史元素都是相同的,可以提出来:
$$ \ell_j =e^{m_{j-1}-m_j}\sum_{i=1}^{j-1}e^{x_i-m_{j-1}} +e^{x_j-m_j}. $$而:
$$ \sum_{i=1}^{j-1}e^{x_i-m_{j-1}}=\ell_{j-1}. $$于是得到:
$$ \boxed{ \ell_j=\ell_{j-1}e^{m_{j-1}-m_j}+e^{x_j-m_j} }. $$这两个部分分别表示:
- $\ell_{j-1}e^{m_{j-1}-m_j}$:把历史指数和换算到新的最大值基准;
- $e^{x_j-m_j}$:加入当前新元素。
Online Softmax 的价值主要在于:
- 可以分块计算;
- 不需要一次保存完整的一行;
- 可以和后续矩阵乘法融合;
- 它是 FlashAttention 的关键基础之一。
需要注意:对于一个独立的 Softmax 算子,即使一次扫描得到了最大值和分母,输出每个 $p_i$ 时仍然需要访问原始元素,除非这些元素一直保存在寄存器或片上存储中。因此,它真正的优势通常体现在分块融合计算中。
总结
Softmax 的优化可以分成三个层面:
- 数值稳定性: 减去最大值、使用 FP32 完成归约和累加、直接计算
log_softmax,并正确处理全 Mask 行; - 访存与调度: 通过算子融合减少 HBM 读写和 Kernel Launch;
- 流式与分块: 使用 Online Softmax 在线更新最大值和归一化分母,为后续融合计算以及 FlashAttention 提供基础。