同一台 M1 Max,同一个注意力算子,512 个 token。
我自研引擎里的旧实现跑 101.539 毫秒。一个刚开源的实现跑 1.496 毫秒。9 轮严格交替,9 轮全胜,67.87 倍。
我第一反应是:这下预填充要起飞了。
然后我做了个除法,整条预填充的上限是 快 5.5%。再把引擎里七个已知 kernel 全假设成零耗时,上限也只有 1.69 倍。而同一台机器上别人家的引擎比我快 8.7 倍。
换 kernel 这条路,换几个都够不着。想直接拿公式的看第 2、3 章;想知道怎么拆别人的「快 X 倍」,看第 5 章。
1. 先说一个我差点没做成的实验
背景很简单。我在给 Qwen3.6-35B-A3B(一个每次只激活一小部分参数的 MoE 模型)写一个只服务这一个模型的推理引擎,跑在一台 64GB 的 M1 Max 上。预填充(把你的整段 prompt 先读一遍的阶段)现在是 96.7 tok/s。同一台机器上,一个现成的闭源引擎 BaseRT 跑出 843.74 tok/s。差 8.7 倍。
九月初 Perplexity 开源了一个叫 Lily 的推理实现,打的恰好是同一个模型、同一种 4-bit 量化格式。它的源码里有一行检查:GPU family 必须 ≥ 10,也就是 M5 往后的芯片。M1 Max 报的是 7。
我第一版结论就写成了「它的预填充 kernel 对老机器没用」。
这个结论没撑多久。那个 843.74,本来就是在这台 M1 上跑出来的。同一台机器的预填充能跑到那个速度,至少说明别急着把锅甩给硬件。
回头一查,更丢人:我自己的仓库里早有三次提交,在这台 M1 上把同一套新版矩阵指令编译过、跑通过、调参调到过 1.98 倍。证据就躺在我自己家里,我没翻。
于是直接上探针:
- 原样拿它的注意力 kernel 源码,用新版编译器编译,成功
- 在 family 7 的设备上建 pipeline,11 个全建成
- 只把它自己的 family 检查从 10 改成 7,它原项目的测试在 M1 上 4 个 case 全过
- 另写一个 CPU fp32 参考实现对拍,98,304 个输出元素 0 个越界;故意把 V 的一行整行加 1,越界数立刻变成 48,706,说明这个判据真能抓错
那个 family >= 10 是产品支持白名单,跟芯片能不能跑没关系。
这就是开头那个 67.87 倍的来历。用脚本从头重跑一遍是 67.73 倍,两次差 0.2%,数字本身是稳的。
问题出在我拿它干什么。
2. 第一个除法:一个算子快了,整条链能快多少
这个算子在整条预填充里占多少时间?我手里有一份 512 token 预填充的分段账,总共 7250.8 毫秒:
| 段 | 干什么的 | 占比 | 这段变成零耗时,整体最多快 |
|---|---|---|---|
| gateup | MoE 专家的 gate/up 投影 | 12.18% | 1.139x |
| gqkv | 线性注意力层的 qkv 投影 | 9.79% | 1.109x |
| asdpa | 全注意力层的注意力算子 | 5.32% | 1.056x |
| gout | 线性注意力层的输出投影 | 5.19% | 1.055x |
| grecur | 线性注意力层的递推 | 4.30% | 1.045x |
| gz | 线性注意力层的 z 投影 | 2.79% | 1.029x |
| lmhead | 最后出词表那一层 | 1.40% | 1.014x |
开头那个 67 倍的算子是第三行,只占 5.32%。
公式就是阿姆达尔定律,一个除法:
整体加速上限 = 1 ÷ (1 − p + p ÷ s)
p = 这段占总时间的比例
s = 这段自己快了多少倍
代进去:1 ÷ (1 − 0.0532 + 0.0532 ÷ 67.87) = 1.055
67 倍的算子,换来整体 +5.5%。s 从 67 涨到无穷大,结果也只到 1.056。占比小的那段,你把它优化到消失,它也只能还你它原本占的那一点。
说句难听的,我在这一步又翻了一次车。算完注意力之后,我顺嘴说「线性注意力的递推那段大概是它的 4 倍,那个才值得搬」。话说完查账,递推是 4.30%,比 5.32% 还小。嘴比账快,这种事一天能发生好几次。
然后把七段一起算:
七段合计 p = 40.97%
七段全部变成零耗时:1 ÷ (1 − 0.4097) = 1.69x
96.7 tok/s × 1.69 ≈ 163.8 tok/s
对照:843.74 tok/s
七个 kernel 全换成「不花时间」的神仙 kernel,引擎也只到 163.8。离 843 差了五倍多。
那 59% 去哪了?
3. 第二个除法:没有任何 kernel 的那 59%
7250.8 毫秒里,七段加起来 2970.6,剩下 4280.2 毫秒没有归属于任何计算段。
我的引擎预填充是逐 token 写的:一个 token 进来,每一层都发一遍 GPU 任务,然后下一个 token 再来一遍。数了一下发射次数:
发射次数 = 层数 × token 数 × 每层每 token 发几次
= 40 × 512 × 23.8 ≈ 486,400 次
每发固定开销 ≈ 未归属时间 ÷ 发射次数
= 4280 ms ÷ 486,400 ≈ 8.80 µs/发
每次发射,CPU 要编码命令、绑缓冲区、插同步。单次 8.8 微秒听着不多,乘以 48 万就是 4 秒多。好比搬一吨砖,一次只搬一块,每趟路上还要排队过闸机,搬砖本身很快,时间全花在过闸机上。
Lily 的写法反过来:一段 prompt 切成最多 4096 token 的块,每层对整块只发一组任务,token 那层循环写在 kernel 内部。512 token 大约是 40 层 × 25 发 ≈ 1000 发,而且跟 prompt 长度无关。
同样的 8.80 µs/发,放在 1000 发的形状下 ≈ 9 ms
486,400 ÷ 1000 ≈ 486 倍的发射次数差
只消掉这 59%:1 ÷ (1 − 0.59) = 2.44x
光是把发射形状改对,上限就比七个 kernel 全免费(1.69x)还高。8.7 倍差距的大头在循环结构里,kernel 质量只是第二层。
所以那个 67 倍的注意力 kernel 真正的价值,是证明了「块状预填充」这套结构的 kernel 在这台机器上跑得起来。它是路线图,单拎出来当补丁不值钱。
4. 第三个判据:看斜率,别看单点
知道要消发射次数之后,我连着做了三件事,每一件都差点被单点数字骗过去。
第一件:换循环顺序。 把「每个 token 走一遍所有层」改成「每一层走一遍所有 token」。听起来像是批量化了。实测发射次数:
N=8 8,280 发
N=16 16,520 发
N=32 33,000 发
拟合:发射次数 = 1030 × N + 40
斜率 1030 一根手指没动。每层每 token 反而从 23.8 发变成 25.8 发。换了遍历顺序,内层那个 token 循环还在 host 上,每次照样发。只看 N=16 时的 16,520 这一个数,你根本看不出它有没有批量化。
判据写死:
斜率 ≈ 0 → token 真的进了 kernel,发射税被消掉
斜率 ∝ N → 只是换了个套法,税原样保留
至少扫三档 N 做线性拟合,单点计数没有意义
第二件:把线性注意力的递推真正搬进 kernel。 独立测试台上斜率从 1.00/token 降到 0.00,M=512 时 512 发变 1 发,段内 10.152 → 4.157 毫秒,2.44 倍,输出跟旧实现逐位相同。
接进整条链再测,只有 1.03 到 1.05 倍。原因是整条链里这一段的基线本来就不是逐 token 了,之前的一轮改造已经把它压到每层 2 发。段内 2.44 倍比的是逐 token 旧版,跟整图的基线不是同一个对照。两个倍数不能相乘,也不能互相替换。
第三件:批量发射。 把一批逐元素小算子合成一次发射,整图发射数 17,860 → 15,840,那个换了循环顺序的版本快了 10.0%,输出逐位不变。
但那个版本仍然慢于原来的逐 token 版(971 vs 871 毫秒)。10% 是真的,它还没赢。
顺带推翻了我自己一个先验:我以为耗时最高的投影段是发射大户,数下来它每 token 只有 10 发;真正的发射大户是一堆耗时不起眼的逐元素小算子,每 token 500 发,占总发射数一半。耗时占比和发射占比是两个分母,优化之前先确认你在压哪一个。
5. 怎么拆别人的「快 X 倍」
这套账反过来用,就是识破别人数字的工具。
第一问:这个倍数是算子级还是端到端? 67.87 倍是真的,但它是算子级。任何「kernel 快了 N 倍」,先要到那个算子在整条链里的占比 p,自己代进 1 ÷ (1 − p + p ÷ s)。p 拿不到的,这个倍数对你没有信息量。
第二问:对照组是谁,计时框了什么? Lily 的官方性能报告在一台 M5 Max 上对比 stock MLX,十个上下文长度点平均下来,解码是 1.285 倍,预填充是 1.032 倍,而且 32K 之后预填充已经输给 MLX。计时排除了 HTTP、分词、聊天模板和模型加载,它还省掉了 MLX 默认会算的全词表 logprobs,一部分快是因为少干活。对照组里也没有 BaseRT 或 oMLX。所以哪怕看到一个更大的整体倍数,也推不出它比这些现役引擎快,更推不出它是更好的服务:它只支持单请求、贪心解码,不支持流式和工具调用。
第三问:「不支持」是硬件墙还是白名单? family >= 10 这种检查,先找一个在目标硬件上真的用过同类指令的例子。找到了,就去掉检查跑一遍官方测试。我这次就是一开始没找,差点把一整条路判了死刑。
6. 这次哪些数不够硬
- 七段占比来自一份较早的 512 token 分段账(7250.8 毫秒),不是今天重测的。而且只要块状改造落地,这些百分比全部作废,得重测。
- 5.5% 和 1.69 倍都是阿姆达尔外推,我没把那个 67 倍的 kernel 接进整条链实测过。
- 8.80 µs/发是用「未归属时间 ÷ 发射次数」算出来的,它默认那 59% 全是发射开销。项目早期另一次独立测量是 3.1 µs/发,同一量级,但差了近 3 倍,所以这个单价我只信量级。
- Lily 的 1000 发是按「40 层 × 约 25 发」估的,没有在它的代码上实测计数。
- 843.74 是那个闭源引擎在这台机器上的数字,我拿不到它的发射次数,「差距主要在循环结构」是从 Lily 的源码结构类推的,没法对它本身验证。
- 整图 A/B 的计时脚本中途被我自己污染过一次:每轮新分配 4MB 并往全局表里追加条目,把配对比值的离散度拉到 27.59 倍。修掉之后降到 1.10,上面 1.03 到 1.05 是修后的数。
7. 下次看到「快 N 倍」,按这个顺序算
- 问它是算子级还是端到端。算子级的,要占比 p。
- 代进 1 ÷ (1 − p + p ÷ s),再算 s 取无穷时的 1 ÷ (1 − p)。后者都不够你的目标,这条路直接放弃。
- 把所有已知段的 p 加起来,算 1 ÷ (1 − Σp)。这是「kernel 全免费」的天花板。
- 用总时间减去已知段,看剩下多少没人认领。剩得多,去数发射次数。
- 发射次数扫至少三档输入长度,拟合斜率。斜率跟着长度涨,先改结构,别换 kernel。
- 改完结构,所有占比重测一遍,再决定换哪个 kernel。
- 看到「不支持你的硬件」,先找一个在同款硬件上用过同类能力的例子,再下结论。