系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:8-1《自注意力数学:Q·K^T / softmax / V》 | 下一篇:8-3《KV 缓存结构:按 token 还是按头存》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的在线 softmax:fp32 的 exp 超过约 88.7 就溢出为 inf,对照朴素、稳定、在线三版板端数据,讲清为何不能先算完 e^x 再除,以及在线 rescale 与减 max 的等价。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、softmax、在线 softmax、减 max、长上下文、RK3588
导语:fp32 的
exp有个天花板:输入超过约 88.7 就溢出成 inf,"先算完 e^x 再除"的朴素 softmax 会直接炸成 NaN。手搓 Qwen 推理引擎的生产内核改用在线 softmax:只维护几个运行量、单遍流式扫完,数学上与两遍稳定版等价。这篇用板端数据把天花板撞出来。
8-1 的公式里,softmax 是先 exp 再归一。很多人第一反应是"那就老老实实算啊"——但 fp32 的 exp 有个天花板:输入超过约 88.7 就溢出成 inf。这一篇先用数据把这个天花板撞出来,再讲引擎在生产内核里用的"在线(online)softmax"为什么绕开了它。
1. 知识点:exp 的溢出与"先减 max"
softmax 有数学恒等式:softmax(x)_i = exp(x_i - max(x)) / Σ_j exp(x_j - max(x))——给所有分数减掉最大值,softmax 结果不变(分子分母同乘 e^-max)。这个恒等式的工程价值:把 exp 的输入从"几十上百"压到"≤0",永远不碰溢出阈值。
于是有两个版本:
- 朴素版(会炸):先
sum = Σ exp(x_i),再w_i = exp(x_i)/sum——只要有一个 x > 88,exp(x)就是 inf,sum 变 inf,所有 w 变 NaN; - 稳定版(两遍):先找 max,再
w_i = exp(x_i - max)/Σ...——输入 ≤0,永不溢出(8-1 参考实现用的就是它)。
那还要"在线"版干嘛? 因为两遍版要求先看到全部分数才能算——而真实推理(长上下文、KV 分块、流式扫描)根本不想把几千个分数全部物化再扫第二遍(要么存不下、要么要二次访存)。在线版用三个运行量 M(当前 max)、S(当前 exp 和)、acc(已加权 V 和),每来一个分数只过一遍,遇新 max 就把已累计的 S 和 acc 乘个 exp(M_old - M_new) 的缩放因子——数学上与两遍版完全等价,却能单遍流式完成。这正是 Day 9 的 flash_attn_single_q_q8_neon 能"扫一遍 KV 就出结果"的根基。
2. 对应代码:两处实现,同一句"先减 max"
引擎参考实现(8-1 的 transformer 与 vllm_attention.c)用的就是稳定两遍版:
/* vllm_attention.c softmax_normalize(第 16–36 行) */
float max_val = scores[0];
for (i = 1; i < n; i++) if (scores[i] > max_val) max_val = scores[i];
float sum = 0.0f;
for (i = 0; i < n; i++) {
scores[i] = expf(scores[i] - max_val); sum += scores[i]; }
if (sum > 0.0f) for (i = 0; i < n; i++) scores[i] /= sum;
生产内核则直接内联在线逻辑(vllm_safetensors.c flash_attn_single_q_q8_neon 第 6627–6666 行):
float M = -1e9f, S = 0.0f;
/* ... 单遍 KV 扫描(逐 token,online softmax 分块语义)... */
float s = /* Q·K^T 该 token 分数 */;
/* online softmax:新 max → rescale 已累积 VKQ */
float vsf;
if (s > M) {
float ms = expf(M - s);
M = s; /* 新 max */
S *= ms; /* 旧 exp 和按 e^(M_old-M_new) 缩放 */
/* acc(VKQ) 同步缩放 ... */
vsf = 1.0f;
} else {
vsf = expf(s - M);
}
S += vsf;
对比着读:参考版"两遍"(先 max 后统一 exp),在线版"单遍"(遇新 max 现场 rescale)——数学同一个 softmax,代价从"全量两遍"降到"流式一遍",误差与两遍版同量级。
3. 改动后果:把 e^x 先算完,看上下文一大就 NaN
写一个 1024 长度的分数数组,让峰值从 10 涨到 90,分别用朴素版(先 exp 再除)与稳定版跑。板端实测(RK3588 / gcc 11.4 / fp32 / 2026-09):
expf(88.0)=1.652e+38 expf(89.0)=inf expf(90.0)=inf
peak= 10 | naive sum=2.905e+06 (ok) | stable sum=131.872498 | online 一致=131.872498
peak= 50 | naive sum=3.223e+23 (ok, 巨大) | stable sum=62.154465 | online 一致=62.154465
peak= 90 | naive sum=inf (inf/nan!) | stable sum=47.495529 | online 一致=47.495529
三行数据三个教训:
- fp32 的 exp 天花板在 ~88.7:
expf(89)=inf——这不是什么稀罕的大数,注意力分数稍不留神就到; - 朴素版在 peak=50 时"没炸但已失真":sum=3.2e23,小分数项在累加里精度被大项淹没;到 peak=90 直接
inf→ 归一后全是 NaN; - 稳定版与在线版逐位一致(两列相同):减 max / 在线 rescale 只是实现差异,数学结果相同——引擎敢在生产内核用在线版,正是因为它和"慢而绝对对"的参考版对得上(5-3 的 1e-7 尺子在这里的又一次使用)。
结论:先算完 e^x 再除,在大上下文 = 定时炸弹;减 max 是必须的,在线 rescale 是"单遍也要减 max"的工程答案。
为什么"大上下文"尤其危险?序列越长,Q·K^T 里出现极端高分 token 的概率越大,而它们正是把 exp 推过 88.7 的元凶——所以这 bug 只在长上下文复现,短测试永远发现不了("压力测试才显形的 bug"的又一个例子)。
4. 学员调试任务
- A 档(板端动手):复刻第 3 节 demo(构造长度 1024、峰值可调的分数数组),打印朴素/稳定两版的 sum,确认 peak=90 时朴素版 inf;再把长度调到 4096、峰值 60 观察"没炸但精度劣化"。
- B 档(纯读源码):读
vllm_attention.c第 16–36 行与vllm_safetensors.c第 6627–6666 行,逐行对应:M/S 在参考版里对应哪两个变量?在线版"遇新 max 时 S 乘以什么"?
预期输出:你能解释"为什么不能先算完 e^x"(fp32 溢出 + 精度淹没)、"减 max 为何数学无害",以及在线版 rescale 的原理与等价值。
收尾
- 本篇源码点名:vllm_attention.c(
softmax_normalize第 16–36 行)、vllm_safetensors.c(flash 内核 online softmax 第 6627–6666 行)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:打分要"回看所有 token",那这些 token 的 K/V 存在哪、怎么排?下一篇 8-3 拆 KV 缓存布局——按 token 还是按头,一行代码定生死。