返回博客

2026.10.04

推理引擎升一个小版本,prefill 从 804 掉到 279:不看源码,怎么判断它慢在哪?

两个月没复测的一把尺子,重测一晚上,排名全换了:同一台 M1 Max 上最快的预填充变成了 llama.cpp 的 928,而我一直当标尺的那个引擎,新版只剩 281。二分到 0.2.3 → 0.2.4 这一步,decode 几乎没动,prefill 掉到三分之一。这篇给一个两点拟合的除法,用四种 prompt 长度把「固定开销变大」和「每个 token 变贵」分开,再拆几个跑分里常见的坑:TTFT 换算的吞吐只是下限、投机解码的速度看你喂什么文本、关掉组件耗时却不变的消融开关。

推理引擎性能方法论Apple Silicon

两个月来,我一直拿一个数当标尺: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. 下次升级推理引擎,按这个顺序测

  1. 升级前后都跑一遍,prefill 和 decode 分开报。只看 decode 的话,这次 3.6% 的变化你会当噪声放过去,可 prefill 已经掉到三分之一了。
  2. prefill 至少测两个长度,选你真实负载会用到的那一段,比如 512 和 2048。算一下「慢了几倍」随长度怎么走:往上走是每 token 变贵,往 1 靠是固定开销变大。
  3. 用 (长耗时 − 短耗时) ÷ (长 token − 短 token) 算每 token 成本,1000 除以它就是这个引擎在你机器上的预填充天花板。
  4. 版本之间做对照时,固定住同一份权重文件。不然你分不清是引擎变了还是模型包变了。
  5. 做消融之前,先拿一个肯定很重的组件当正对照,确认开关关了以后耗时真的会变。
  6. 你当标尺的那个数,每过一两个月拿所有候选重测一遍。我那把尺子自己一点没动,是别人在两个月里悄悄超过了它。