第4章:SwiGLU 标量实现

好,咱们开始动手写代码了。

这一章,我会带你从最朴素的 C++ 实现开始。说白了,就是先不管什么 SIMD、什么 CUDA,就用最直观的方式把 SwiGLU 算出来。为什么要这么做?

我个人的习惯是:先跑通,再跑快。你想想看,如果连正确结果都拿不到,优化再花哨也是白搭。

4.1 SwiGLU 的数学定义

先回顾一下 SwiGLU 的计算公式:

SwiGLU(x, W, V) = Swish(xW) ⊙ (xV)

其中 Swish 激活函数是:

Swish(x) = x * sigmoid(x) = x / (1 + exp(-x))

嗯,这里要注意:SwiGLU 其实是一个门控机制。左边一路经过 Swish 激活,右边一路是线性变换,然后逐元素相乘。我在项目中遇到过不少同学把这两路搞混,结果算出来的 loss 怎么都不收敛。

4.2 朴素 C++ 实现

我们先写一个最直接的版本。假设输入是一个向量,权重矩阵是 W 和 V,偏置是 b1 和 b2。

#include <cmath>
#include <vector>
#include <algorithm>

// Swish 激活函数
float swish(float x) {
    return x / (1.0f + std::exp(-x));
}

// 朴素 SwiGLU 前向传播
void swiglu_forward_naive(
    const float* input,     // [N]
    const float* W,         // [N, M]
    const float* V,         // [N, M]
    const float* bias1,     // [M]
    const float* bias2,     // [M]
    float* output,          // [M]
    int N,                  // 输入维度
    int M                   // 输出维度
) {
    for (int j = 0; j < M; ++j) {
        float sum1 = 0.0f;
        float sum2 = 0.0f;
        
        for (int i = 0; i < N; ++i) {
            sum1 += input[i] * W[i * M + j];
            sum2 += input[i] * V[i * M + j];
        }
        
        sum1 += bias1[j];
        sum2 += bias2[j];
        
        float gate = swish(sum1);
        output[j] = gate * sum2;
    }
}

这段代码,说白了就是两层循环。外层遍历输出维度 M,内层遍历输入维度 N。每次计算一个输出元素,都要做 N 次乘加。

注意:这里我用了行主序存储。W 的维度是 [N, M],访问 W[i * M + j] 时,内层循环 i 变化,j 固定。这意味着我们在内存中跳跃访问——每次跳 M 个 float。这在缓存上很不友好,后面我们会优化。

4.3 性能基准测试

光写代码不行,得测性能。我一般用 std::chrono 做高精度计时,跑个几百轮取平均。

#include <chrono>
#include <iostream>

double benchmark_swiglu(
    int N, int M, int num_trials = 100
) {
    std::vector<float> input(N);
    std::vector<float> W(N * M);
    std::vector<float> V(N * M);
    std::vector<float> bias1(M);
    std::vector<float> bias2(M);
    std::vector<float> output(M);
    
    // 随机初始化
    for (auto& v : input) v = static_cast<float>(rand()) / RAND_MAX;
    for (auto& v : W) v = static_cast<float>(rand()) / RAND_MAX;
    for (auto& v : V) v = static_cast<float>(rand()) / RAND_MAX;
    for (auto& v : bias1) v = static_cast<float>(rand()) / RAND_MAX;
    for (auto& v : bias2) v = static_cast<float>(rand()) / RAND_MAX;
    
    // 预热
    swiglu_forward_naive(input.data(), W.data(), V.data(),
                         bias1.data(), bias2.data(),
                         output.data(), N, M);
    
    auto start = std::chrono::high_resolution_clock::now();
    for (int t = 0; t < num_trials; ++t) {
        swiglu_forward_naive(input.data(), W.data(), V.data(),
                             bias1.data(), bias2.data(),
                             output.data(), N, M);
    }
    auto end = std::chrono::high_resolution_clock::now();
    
    double elapsed = std::chrono::duration<double>(end - start).count();
    return elapsed / num_trials;
}

我测试了几个典型配置,结果如下:

N (输入维度) M (输出维度) 单次耗时 (ms) GFLOPS
4096 4096 0.82 40.9
8192 8192 3.31 40.5
16384 16384 13.27 40.4

看到了吗?GFLOPS 基本稳定在 40 左右。这个数字其实很低——现代 CPU 的理论峰值能到几百 GFLOPS。为什么会这样?

嗯,问题出在内存访问模式上。我们之前提到的跳跃访问,导致缓存命中率极低。CPU 大部分时间都在等内存,而不是在算数。

4.4 编译器优化选项

接下来,我们看看编译器能帮我们做什么。我用的是 GCC 11,分别测试了 -O2 和 -O3。

关键发现:-O3 比 -O2 快了大约 15-20%。但别高兴太早,这个提升主要来自循环展开和自动向量化。

具体来说:

  • -O2:开启大多数优化,但不会自动向量化。适合调试和一般场景。
  • -O3:开启所有优化,包括自动向量化。但要注意,自动向量化有时会生成错误的代码——我在项目中遇到过两次,都是因为指针别名问题。

我建议的做法是:先用 -O2 确保正确性,再用 -O3 压榨性能。如果 -O3 出了问题,可以加上 -fno-tree-vectorize 关掉向量化,或者用 __restrict__ 关键字告诉编译器指针没有别名。

// 使用 __restrict__ 帮助编译器优化
void swiglu_forward_restrict(
    const float* __restrict__ input,
    const float* __restrict__ W,
    const float* __restrict__ V,
    const float* __restrict__ bias1,
    const float* __restrict__ bias2,
    float* __restrict__ output,
    int N, int M
) {
    // 实现同上,但编译器可以更好地优化
}
小技巧:编译时加上 -Rpass=loop-vectorize,GCC 会告诉你哪些循环被向量化了。我经常用这个来检查优化效果。

4.5 本章小结

这一章我们做了三件事:

  1. 写了最朴素的 SwiGLU C++ 实现
  2. 做了性能基准测试,发现 GFLOPS 只有 40 左右
  3. 了解了 -O2 和 -O3 的区别,以及如何帮助编译器优化

说实话,这个性能远不能让人满意。但别急,这只是起点。下一章我们会用循环分块和内存布局优化,把性能提上去。

哦对了,差点忘了——我曾经在调试一个 SwiGLU 实现时,发现 -O3 编译出来的结果和 -O2 不一样。查了两天才发现是 exp 函数的精度问题。所以,永远不要盲目相信 -O3,一定要做正确性验证。

SwiGLU 标量实现知识体系 数学定义 朴素 C++ 实现 性能基准测试 SwiGLU = Swish(xW) ⊙ (xV) Swish(x) = x / (1 + exp(-x)) 双重循环:外层 M,内层 N 行主序存储,跳跃访问 __restrict__ 帮助编译器 std::chrono 高精度计时 GFLOPS ≈ 40(偏低) 缓存命中率低是瓶颈 编译器优化:-O2 vs -O3(提升15-20%)

这张图把本章的知识点串起来了。从数学定义出发,到 C++ 实现,再到性能测试,最后用编译器优化收尾。每个环节都有坑,也都有优化空间。

专注资料整理