两个月来,我一直拿一个数当标尺:843.74。
那是一个闭源 Mac 推理引擎 BaseRT 在我这台 M1 Max 上,跑 Qwen3.6-35B-A3B 的预填充速度(tok/s)。我自己在写的引擎每轮优化完,都拿去跟它比。
昨晚重测了一遍。同一台机器,同一个模型。最快的变成了 llama.cpp,927.9。而那个当了两个月标尺的引擎,最新版只剩 280.7,掉到三分之一。
decode 呢?96.2 → 92.8,只慢了 3.6%。如果你平时只看生成速度,升级完根本察觉不到。
想直接拿除法的看第 3 章;想知道我那个「关掉组件」的实验为什么什么都没关掉,看第 4 章。
1. 一把尺子放两个月会怎样
先交代口径。预填充(prefill)就是模型把你整段 prompt 先读一遍的阶段,决定你按下回车后多久看到第一个字;decode 是之后一个字一个字往外蹦的阶段。下面 pp512 指 512 个 token 的 prompt 读完的吞吐,tg128 指连续生成 128 个 token 的吞吐。
同一晚、串行、一次只跑一个引擎,测之前把系统的照片分析进程暂停掉(它吃 CPU 很凶,前两天就因为它让我一半的测量作废):
| 引擎 | 权重 | pp512 | tg128 |
|---|---|---|---|
| llama.cpp b11382 | Q4_K_M(4/6 bit 混合) | 927.9 | 61.3 ± 10.5 |
| BaseRT 0.2.0(旧版) | 官方 4bit 包 | 850.1 | 96.2 |
| 我自己的引擎 | 自己的 4bit 格式 | 830.6 | 90.5 |
| Splash 社区 M1 移植版 | 官方包 | 783.7(下限) | 81.2 / 125.0 |
| mlx-lm 0.32 | 社区 4bit | 674.9 | 72.2 |
| BaseRT 0.2.6(新版) | 官方 4bit 包 | 280.7 | 92.8 |
两件事值得先说。
第一,旧版 BaseRT 自己是稳的。八月测 843.74,这次 850.1,差不到 1%。尺子本身没坏,坏的是我的假设:我默认它一直是最快的。七月我记的 llama.cpp 在这台机器上预填充还明显落后,两个月里它自己追上来了,我一次都没回头看。
第二,这张表不完全是苹果比苹果。llama.cpp 用的 Q4_K_M 有一部分层是 6 bit,mlx-lm 是另一种 4bit,我自己的引擎是我自己的量化格式,而且是在我自己的评测脚本里测的,跟 llama-bench 不是同一个计时口径。排序可以看,小数点后面的差别别太当真。
2. 二分:哪一版掉下去的
BaseRT 的好处是每个版本都能单独下载,而且新旧版本能读同一个模型包。所以可以用同一份权重逐版本跑 pp512,权重这个变量直接排除:
- 0.2.0:850
- 0.2.1:807
- 0.2.2:807
- 0.2.3:804
- 0.2.4:279
- 0.2.5:275
- 0.2.6:279
一步从 804 掉到 279。前面 850 → 807 那 5% 也是真的,但跟这一刀比不值一提。
0.2.6 的 release note 里提到,0.2.4 和 0.2.5 在 M1 上跑这个模型会输出乱码,0.2.6 修了。所以大概率的故事是:0.2.4 在 M1 上换了一条新的计算路径,第一版连结果都是错的,后来把结果修对了,速度没修回来。这是推断,我没有证据证明是哪个 kernel。
那接下来的问题是:不看源码,能知道它慢在哪一截吗?
3. 一个除法:固定开销,还是每个 token 都变贵了
慢有两种长相。
一种是固定开销变大了:多了一次初始化、多了一次同步、多建了一个缓冲区。不管 prompt 多长,都多花那么几十毫秒。
另一种是每个 token 都变贵了:某个按 token 数干活的 kernel 变慢了。prompt 越长,多花的越多。
pp512 一个数分不出这两种,因为吞吐 = token 数 ÷ 耗时,两种病都会让它变小。要分开,得多测几个长度,然后做一次两点拟合:
耗时 ≈ 固定开销 + 每 token 成本 × token 数
每 token 成本 = (长 prompt 耗时 − 短 prompt 耗时) ÷ (长 token 数 − 短 token 数)
固定开销 = 耗时 − 每 token 成本 × token 数
长 prompt 的吞吐上限 ≈ 1000 ÷ 每 token 成本(毫秒)
就是初中的一次函数,斜率是每个 token 的价钱,截距是固定开销。
我在 0.2.3 和 0.2.4 上各测了四个长度(单位毫秒):
| prompt 长度 | 0.2.3 | 0.2.4 | 慢了几倍 |
|---|---|---|---|
| 32 | 129.8 | 223.9 | 1.72 |
| 128 | 257.5 | 610.2 | 2.37 |
| 512 | 636.4 | 1836.6 | 2.89 |
| 2048 | 2050.7 | 6869.0 | 3.35 |
先看最右边一列,不用算就能看出个大概:prompt 越长,慢得越多。固定开销型的回退正好相反,prompt 越长,那几十毫秒被摊得越薄,倍数会往 1 靠。
再拿 512 和 2048 两个点代公式:
- 0.2.3:每 token (2050.7 − 636.4) ÷ 1536 = 0.92 毫秒,固定开销 636.4 − 0.92 × 512 ≈ 165 毫秒
- 0.2.4:每 token (6869.0 − 1836.6) ÷ 1536 = 3.28 毫秒,固定开销 1836.6 − 3.28 × 512 ≈ 159 毫秒
固定开销几乎没变,每个 token 贵了 2.36 毫秒。新版慢的全在按 token 干活的那部分。
吞吐上限也对得上:0.2.3 是 1000 ÷ 0.92 ≈ 1086,实测 pp2048 已经爬到 999;0.2.4 是 1000 ÷ 3.28 ≈ 305,实测 pp2048 是 298,基本贴着天花板了。也就是说,新版在这台机器上,prompt 再长也超不过 305 tok/s。
顺手说一句老实话:这个一次函数在短 prompt 上不太准。用 512/2048 两点拟出来的线,预测 0.2.3 跑 32 个 token 要 194 毫秒,实测只要 130。短 prompt 上 GPU 根本喂不饱,每 token 成本比长 prompt 高,线是弯的。所以拟合要用你真正关心的那一段长度,我关心的是几百到几千 token 的 prompt,就用 512 和 2048。逐段算差值,0.2.4 每 token 多出来的是 2.2 到 2.7 毫秒,量级一致,结论不变。
这个除法的好处是它对任何引擎都能用,不需要源码、不需要 profiler,只需要你能控制 prompt 长度、能读到耗时。
4. 关掉一个组件,耗时一毫秒都没少
确定了是「每 token 变贵」,下一步自然是想知道哪个组件变贵了。
BaseRT 有一组 BASERT_SKIP_* 环境变量,名字看着就是干这个的:跳过 MoE 路由专家、跳过注意力、跳过共享专家、跳过输出头。每次关一个,看耗时少多少,少得最多的那个就是元凶。
结果是这样的(pp512,单位毫秒):
- 0.2.3 什么都不跳:636.4
- 0.2.3 跳过 MoE 路由专家:634.4
- 0.2.3 跳过注意力:636.4
一个 35B 的 MoE 模型,跳过全部路由专家,耗时只少了 2 毫秒。这不可能。专家层是这个模型的大头,真跳过了耗时应该塌一大截。
所以正确的读法是:这些开关在 bench 程序里根本没生效。它们可能只在服务模式里接了线,或者在某个版本被删了,文档没跟上。我差点就把「跳过 MoE 耗时不变」读成「MoE 不是瓶颈」,往错的方向查下去。
判据很简单:你关掉的东西如果本来就该占一大块时间,关掉后耗时却几乎不变,先怀疑开关,别怀疑组件。 做消融之前,拿一个你确定很重的组件当正对照,先确认开关真的会让耗时变。
后面我又试了 13 个调 kernel 路由的环境变量,在 0.2.4 和 0.2.6 上一个个开,最好的一个跑到 283.6,从 279 涨了不到 2%。dense 矩阵乘的 kernel 种类、形状、调用次数,两版 trace 一模一样。剩下的嫌疑在 MoE 或线性注意力那条新路径上,要定位到具体 kernel 得上 Metal 的 GPU capture。这是上游的事,对我的用法没影响(继续用 0.2.0),我在这儿停手了。
5. 别人报的数,先问这三句
这一晚还顺便踩到几个读跑分时很容易被骗的地方。
一,用首字延迟换算出来的预填充,只是下限。 Splash 那个移植版没有 bench 子命令,我只能起服务,用 512 token 的 prompt 测首字延迟(TTFT),再拿 token 数除以它。这个时间里除了预填充,还包着 HTTP 往返、分词、第一步 decode。所以 783.7 只能说「至少这么快」。
拿第 3 章的除法套一下:它在 509 token 时 TTFT 649.4 毫秒,1488 token 时 1692.3 毫秒,每 token 1.07 毫秒,固定开销约 107 毫秒,长 prompt 上限约 939。这是两次运行各取一个点拼出来的,只看量级。
同样的算法放到 BaseRT 0.2.3 上:pp512 是 804,天花板却是 1086。两个引擎 pp512 差 20,天花板差 150。单一长度的吞吐只是曲线上的一个点,排名会随 prompt 长度变。 别人只报一个 pp512,你拿不到他的天花板。
二,开着投机解码的 decode 速度,取决于你喂的是什么文本。 投机解码就是让一个小模型先猜后面几个词,大模型一次性验,猜对白赚。Splash 这版的投机解码关不掉,源码里没有开关。同一个引擎、同一个模型、同样生成 128 个 token:
- 让它续写随机文本:81.2 tok/s
- 让它写正常的散文:125.0 tok/s
差 1.54 倍。散文好猜,随机文本难猜,就这么简单。谁跟你说「我这个引擎 decode 125」,先问他测的是什么内容,再问投机解码开没开。拿它跟不带投机解码的 96.2 直接比,比的是两回事。
三,看一眼误差条。 llama.cpp 的 tg128 是 61.3 ± 10.5,标准差占了均值的 17%,原因我没查。这种数拿来排名,61 和 72 的先后其实不太站得住。
6. 这一晚的毛病
- 只有一台机器、一个模型。 0.2.4 的回退我只在 M1 Max 上见过,release note 也说问题是 M1 特有的。M2 以后的芯片很可能没这个事,别拿我的数去劝别人别升级。
- 0.2.0 不在二分那一轮里。 850.1 是同一晚单独跑的,跟 0.2.1 到 0.2.6 那一串不是连着测的。
- 二分每个版本只跑了一轮。 bench 自带重复,报出来的标准差都在 4 以内,所以我没再多跑。但温度、后台进程这些整轮级别的波动,单轮看不出来。
- 我自己的引擎是在另一套脚本里测的。 830.6 来自我自己的评测环境(跟旧版本交替跑,防止谁先跑谁占便宜),没过 llama-bench 或 basert-bench。它跟表里其他数之间可能有几个百分点的口径差。
- 回退的根因没确诊。 「换了一条新的 MoE 或线性注意力路径」是推断,依据是 dense GEMM 没变加上 release note,没有 kernel 级证据。
- 短 prompt 段不是直线。 第 3 章的除法在 128 token 以下会高估耗时,上面已经说了,结论只对几百 token 往上成立。
7. 下次升级推理引擎,按这个顺序测
- 升级前后都跑一遍,prefill 和 decode 分开报。只看 decode 的话,这次 3.6% 的变化你会当噪声放过去,可 prefill 已经掉到三分之一了。
- prefill 至少测两个长度,选你真实负载会用到的那一段,比如 512 和 2048。算一下「慢了几倍」随长度怎么走:往上走是每 token 变贵,往 1 靠是固定开销变大。
- 用 (长耗时 − 短耗时) ÷ (长 token − 短 token) 算每 token 成本,1000 除以它就是这个引擎在你机器上的预填充天花板。
- 版本之间做对照时,固定住同一份权重文件。不然你分不清是引擎变了还是模型包变了。
- 做消融之前,先拿一个肯定很重的组件当正对照,确认开关关了以后耗时真的会变。
- 你当标尺的那个数,每过一两个月拿所有候选重测一遍。我那把尺子自己一点没动,是别人在两个月里悄悄超过了它。