第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 次乘加。
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。
具体来说:
- -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 本章小结
这一章我们做了三件事:
- 写了最朴素的 SwiGLU C++ 实现
- 做了性能基准测试,发现 GFLOPS 只有 40 左右
- 了解了 -O2 和 -O3 的区别,以及如何帮助编译器优化
说实话,这个性能远不能让人满意。但别急,这只是起点。下一章我们会用循环分块和内存布局优化,把性能提上去。
哦对了,差点忘了——我曾经在调试一个 SwiGLU 实现时,发现 -O3 编译出来的结果和 -O2 不一样。查了两天才发现是 exp 函数的精度问题。所以,永远不要盲目相信 -O3,一定要做正确性验证。
这张图把本章的知识点串起来了。从数学定义出发,到 C++ 实现,再到性能测试,最后用编译器优化收尾。每个环节都有坑,也都有优化空间。