前言

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐