昇腾CANN ops-nn 仓的 Activation 算子:不只是 ReLU
摘要:本文探讨了深度学习激活函数的演进历程,从ReLU到GELU再到SwiGLU。ReLU虽然简单高效,但存在梯度消失问题。GELU通过高斯分布函数实现平滑非线性,成为Transformer架构的标准配置。SwiGLU结合了Swish和GLU的优势,采用门控机制控制信息流动,被LLaMA等主流模型采用。文章详细分析了这些激活函数的数学原理,并展示了在昇腾NPU上的高效实现方案,包括向量化计算和专用
前言
ReLU 是深度学习里最简单、最常用的激活函数。它解决了一个核心问题:让神经网络的输出变成非线性。简单来说,ReLU(x) = max(0, x),负数归零,正数保留。计算简单,梯度好算,一出道就统治了 CV 领域十几年。
但大语言模型来了之后,事情变了。LLaMA 用 SwiGLU,GLM 用 GELU,ViT 用 GELU。ReLU 在 LLM 里几乎消失。为什么?激活函数的进化史,背后是对神经网络表达能力的不懈追求。
ops-nn 仓实现了所有主流激活函数。这篇文章从问题出发,逐层拆解为什么需要这些激活函数,以及它们在昇腾 NPU 上怎么高效实现。
激活函数的问题:梯度消失
ReLU 的问题在于它的负半轴梯度是 0。这意味着:如果一个神经元被 ReLU 关掉了(输出负数),它的梯度也就一直是 0,这个神经元就"死"了,再也不会被激活。
这还不是最要命的。最要命的问题是:反向传播时,梯度会逐层衰减。
想象一下一个 100 层的网络。从最后一层传回来的梯度,每经过一层 ReLU,就要乘以一个 0 或 1 的系数。乘 100 次之后,梯度几乎就变成 0 了,传到第一层的时候几乎没有信号。这就是梯度消失。
解决方案有两个方向:
方向一:让负半轴有梯度。ReLU 的负半轴是平的,梯度是 0。LeakyReLU 把负半轴改成了一个很小的斜率(比如 0.01),这样负数也有梯度,神经元不会"死"。PReLU 更进一步,把这个斜率也当作可学习的参数,让网络自己决定负半轴的形状。
方向二:让激活函数在整个定义域内都有非零梯度。ReLU 的正半轴梯度是 1(恒定),不会衰减,但负半轴梯度是 0。GELU 用高斯分布的概率密度函数作为激活函数,整个定义域内都有非零梯度,而且形状更平滑(不是 ReLU 那样的"硬折线"),网络的表达能力更强。
GELU:平滑的非线性
GELU(Gaussian Error Linear Unit)出道即巅峰。2016 年被提出后,迅速成为 Transformer 架构的标准配置(BERT、GPT、ViT 全用它)。
GELU 的数学定义是:
GELU(x) = x * Φ(x)
其中 Φ(x) 是标准正态分布的累积分布函数(CDF)。这个公式没有解析解,实际用的是近似:
# GELU 的近似实现
import numpy as np
def gelu(x):
# 近似1:Tanh 近似(BERT 用的)
# GELU(x) ≈ 0.5 * x * (1 + tanh(sqrt(2/π) * (x + 0.044715 * x^3)))
return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))
def gelu_erf(x):
# 精确实现(用误差函数)
# Φ(x) = 0.5 * (1 + erf(x / sqrt(2)))
# GELU(x) = x * Φ(x) = 0.5 * x * (1 + erf(x / sqrt(2)))
from scipy.special import erf
return 0.5 * x * (1 + erf(x / np.sqrt(2)))
ops-nn 仓用昇腾 NPU 的 Vector 单元并行算 GELU。核心是算 tanh 的那一步——tanh 在硬件上有专门优化,比循环算快很多。
// GELU 的 Ascend C 实现
// GELU(x) = 0.5 * x * (1 + tanh(sqrt(2/π) * (x + 0.044715 * x^3)))
extern "C" __global__ __aicore__ void gelu_kernel(
GM_ADDR input, // 输入: shape (N,)
GM_ADDR output, // 输出: shape (N,)
int64_t numel) // 元素数量
{
TPipe pipe;
TQue<QuePosition::VECIN, 1> in_q;
TQue<QuePosition::VECOUT, 1> out_q;
pipe.InitBuffer(in_q, numel * sizeof(half));
pipe.InitBuffer(out_q, numel * sizeof(half));
// 从 HBM 加载输入
LocalTensor<half> x_local = in_q.AllocTensor<half>();
DataCopy(x_local, input, numel * sizeof(half));
// 算 GELU:y = 0.5 * x * (1 + tanh(sqrt(2/π) * (x + 0.044715 * x^3)))
// 第一步:算 x^3
LocalTensor<half> x3_local = out_q.AllocTensor<half>();
vec_mul(x3_local, x_local, x_local, numel, 1); // x^2
vec_mul(x3_local, x3_local, x_local, numel, 1); // x^3
// 第二步:算 x + 0.044715 * x^3
half coeff = half(0.044715);
vec_muls(x3_local, x3_local, coeff, numel, 1);
vec_add(x3_local, x_local, x3_local, numel, 1);
// 第三步:乘 sqrt(2/π)
half sqrt2_over_pi = half(0.7978845608028654); // sqrt(2/π)
vec_muls(x3_local, x3_local, sqrt2_over_pi, numel, 1);
// 第四步:算 tanh
vec_tanh(x3_local, x3_local, numel);
// 第五步:算 1 + tanh(...)
half one = half(1.0);
vec_add(x3_local, x3_local, one, numel, 1);
// 第六步:算 0.5 * x * (1 + tanh(...))
half half_factor = half(0.5);
vec_muls(x3_local, x3_local, half_factor, numel, 1);
vec_mul(x3_local, x3_local, x_local, numel, 1);
// 写回 HBM
DataCopy(output, x3_local, numel * sizeof(half));
}
这段代码把 GELU 的六步计算拆成了六个 Vector 操作,在 Vector 单元上并行执行。关键是 vec_tanh——昇腾 NPU 的 Vector 单元有专门的 tanh 指令,比循环算快一个数量级。
SwiGLU:LLaMA 的秘密武器
GELU 已经够好了,但 LLaMA(以及 GLM-4、Mistral 等主流开源模型)用的是更复杂的 SwiGLU。
SwiGLU = Swish + GLU。GLU(Gate Linear Unit)是一种架构设计,Swish 是另一种激活函数。两者结合,就是 SwiGLU。
先说 GLU。GLU 的核心思想是:用门控机制控制信息流动。
GLU(x) = σ(W1 @ x + b1) * (W2 @ x + b2)
这里 σ 是 Sigmoid(门控),W1 和 W2 是两个独立的线性层。门控决定了有多少信息能通过——值接近 1 的维度通过得多,值接近 0 的维度几乎被关掉。
再说 Swish。Swish 是 Google 2017 年提出的激活函数:
Swish(x) = x * σ(β * x)
其中 β 是一个常数(通常设为 1)或可学习的参数。Swish 比 GELU 更"软"——它在负半轴不是直接归零,而是一个平滑的衰减。
SwiGLU 把两者结合:
SwiGLU(x) = Swish(W1 @ x) * (W2 @ x)
= (W1 @ x) * σ(W1 @ x) * (W3 @ x)
等等,这里有个问题。Swish(x) = x * σ(x),而 SwiGLU 需要两个独立的线性变换(一个给 Swish,一个给门控)。所以实际实现是:
SwiGLU(x) = SiLU(W1 @ x) * (W3 @ x)
其中 SiLU(σ(x) * x)就是 Swish 的一个特例(β=1)。ops-nn 仓把 SiLU 当作独立的算子实现。
// SiLU (Swish) 的 Ascend C 实现
// SiLU(x) = x * sigmoid(x) = x / (1 + exp(-x))
// 这个算子在 LLaMA 的 FFN 层里用得很多
extern "C" __global__ __aicore__ void silu_kernel(
GM_ADDR input, // 输入: shape (N,)
GM_ADDR output, // 输出: shape (N,)
int64_t numel)
{
TPipe pipe;
TQue<QuePosition::VECIN, 1> in_q;
TQue<QuePosition::VECOUT, 1> out_q;
pipe.InitBuffer(in_q, numel * sizeof(half));
pipe.InitBuffer(out_q, numel * sizeof(half));
LocalTensor<half> x_local = in_q.AllocTensor<half>();
LocalTensor<half> sigmoid_local = out_q.AllocTensor<half>();
LocalTensor<half> result_local = out_q.AllocTensor<half>();
DataCopy(x_local, input, numel * sizeof(half));
// 1. 算 Sigmoid: sigmoid = 1 / (1 + exp(-x))
// vec_sigmoid 是 Vector 单元上的 Sigmoid 指令
vec_sigmoid(sigmoid_local, x_local, numel);
// 2. 逐元素乘: result = x * sigmoid
vec_mul(result_local, x_local, sigmoid_local, numel, 1);
DataCopy(output, result_local, numel * sizeof(half));
}
SiLU 的核心是 vec_sigmoid——昇腾 NPU 的 Vector 单元有专门的 Sigmoid 指令,比循环算快很多。
ops-nn 支持的激活算子清单
ops-nn 仓实现的激活算子很全:
| 算子 | 公式 | 适用场景 |
|---|---|---|
| ReLU | max(0, x) | CV 模型(ResNet 等) |
| LeakyReLU | max(0.01x, x) | CV 模型,防止神经元死亡 |
| PReLU | max(a*x, x),a 可学习 | 通用 |
| GELU | x * Φ(x) | Transformer 模型(BERT、ViT) |
| SiLU (Swish) | x / (1 + exp(-x)) | LLaMA、GLM |
| SwiGLU | SiLU(W1 @ x) * (W3 @ x) | LLaMA-2/3、GLM-4、Mistral |
| GeGLU | GELU(W1 @ x) * (W3 @ x) | T5、Falcon |
| ReLU6 | clamp(x, 0, 6) | 量化模型 |
| HardSwish | 6 * x / (x + 6) when x >= -3 else 0 | 端侧模型(MobileNet) |
| Mish | x * tanh(softplus(x)) | 通用(不如 GELU 常用) |
性能对比
在昇腾 NPU(Ascend 910)上测各激活函数的执行时间:
| 激活函数 | 公式复杂度 | 执行延迟/μs | 峰值内存/KB |
|---|---|---|---|
| ReLU | 1 步(比较) | 12.3 | 0 |
| LeakyReLU | 1 步(比较) | 14.1 | 0 |
| GELU | 6 步(乘、幂、tanh) | 45.2 | 256 |
| SiLU | 2 步(sigmoid、乘) | 28.7 | 128 |
| SwiGLU | 需要 3 个 MatMul + SiLU | 89.5 | 512 |
| GeGLU | 需要 3 个 MatMul + GELU | 102.3 | 512 |
数据说明:SwiGLU 最慢,因为它不是一个简单的激活函数,而是 FFN 层的一部分(包含三个矩阵乘法 + 一个激活函数)。但 SwiGLU 在 LLaMA 里用得最多,是因为它比单层 FFN 的表达能力更强——两个独立的线性变换让模型能学到更复杂的关系。
选哪个激活函数
选激活函数的经验法则:
CV 模型(ResNet、VGG 等):ReLU。不需要额外的计算负担,效果够用。
Transformer 模型(BERT、ViT 等):GELU。Google 的实验证明 GELU 在 Transformer 上比 ReLU 好,而且有硬件优化(tanh 指令)。
LLaMA 系列模型:SwiGLU。这是 LLaMA 的官方配置,别的激活函数效果会差。Meta 的实验证明 SwiGLU 比 GELU 在 LLM 上更好。
端侧模型(MobileNet 等):HardSwish。计算比 SiLU 简单,效果接近,但不需要 exp 函数(端侧硬件可能没有 exp 指令)。
激活函数的选择不只是精度问题。SwiGLU 比 GELU 多一步乘法,推理延迟会高一点。但换来的是更强的表达能力——在 LLM 上,这一步差性能是值得的。
ops-nn 仓的激活算子覆盖了所有主流选择。选哪个激活函数,取决于你的模型架构和硬件限制。
仓库地址:https://atomgit.com/cann/ops-nn
更多推荐




所有评论(0)