返回博客

2026.10.05

落后 llama.cpp 12%,四个 agent 调了 8 小时只涨 0.66%:两个引擎的差距,到底落在哪个算子上?

我自己写的推理引擎在 M1 Max 上预填充 830.6 tok/s,llama.cpp 是 927.9。四个 AI agent 盯着 MoE 调了 8 小时,涨了 0.66%。停下来花一晚上逐算子对账,发现 MoE 恰好是我比它快的那一块,差距全在 dense 投影和转置上。照着账再跑 8 小时,涨了 9.9%。这篇讲怎么在两个引擎之间做逐算子对账:一个补发差分、一个覆盖率除法、一个按字节判断是不是卡带宽,以及单算子跑分为什么会把 MoE 少算 60%。

推理引擎性能方法论Apple Silicon

四个 AI agent,8 小时,几十次自动评测,预填充速度涨了 0.66%。

同一台 M1 Max,同一个模型。我自己写的引擎跑 830.6 tok/s,llama.cpp 跑 927.9,它快 12%。那 8 小时里,四个 agent 有三个在改 MoE 专家那一块,因为我上一轮下的判断是「MoE 缺口最大」。

后来我停下来花了一晚上,把两个引擎按算子逐类对了一遍账。结果 MoE 是我比它快的那一块:245.9 毫秒对 270.1。

三个 agent 花 8 小时在优化一个已经领先的地方。

照着账换了方向,再跑 8 小时,涨了 9.9%,到了 llama.cpp 的 98.8%。

想直接拿对账方法的看第 2、3 章;想知道单算子跑分怎么把 MoE 少算 60%,看第 4 章。

1. 先说我是怎么猜错的

背景交代一句。我在给 Qwen3.6-35B-A3B 写一个只服务这一个模型的推理引擎。这是个 MoE 模型,内部分成 256 个「专家」小网络,每个 token 只叫醒其中 8 个干活。预填充就是模型把你整段 prompt 先读一遍的阶段,下面的数字都是 512 个 token 的 prompt(pp512)。

前两轮我各下过一个方向判断,两次都错:

  • 第九轮我说「dense 投影已经饱和,别碰了」。下一轮恰好在投影上拿到了 +3.9%。
  • 第十轮我说「MoE 的 gate_up 离峰值最远,该主攻它」。第十一轮三个 agent 扑上去,所有变体持平或回退,最差 -40%。

两次判断有个共同点:依据都是我自己引擎内部的占比,从来没跟对手同口径比过。我知道 MoE 在我这里占了很多时间,所以觉得它该优化。但它占得多不代表它比别人慢。对手的 MoE 可能占得更多。

说白了,我一直在看自己的体检报告,没看过对方的。

2. 第一步:先把对手拆开,用覆盖率验证拆得对不对

llama.cpp 自带两个工具正好能干这个:一个能导出某次推理的计算图里有哪些算子、什么形状;一个能单独给每个算子计时。

做法是两步乘法:

某类算子时间 = 单次耗时 × 这类算子在图里出现的次数
覆盖率 = Σ(各类算子时间) ÷ 端到端实测时间

次数我没有靠模型结构去推,直接在真实的图里数节点:3845 个节点,去掉 1490 个只是换个视角看内存、不干活的节点,剩 2355 个。然后拿模型结构反过来核对:40 层,其中 30 层线性注意力、10 层全注意力,每层 gate 和 up 各一次 MoE 乘法,80 次,对得上。

加总下来:

各类合计 528.0 ms
llama-bench pp512 实测 927.87 tok/s → 512 ÷ 927.87 = 551.8 ms
覆盖率 = 528.0 ÷ 551.8 = 95.7%

95.7%,看着很漂亮。dense 投影 209.6 ms 占 38%,MoE gate_up 113 ms,线性注意力那块 76.5 ms。

这个漂亮的数,后面我会亲手拆掉它。先说为什么:覆盖率接近 100% 只说明总和对得上,说明不了每一类都对。两个方向相反的误差抵消一下,总和照样漂亮。第 4 章就是这么回事。

3. 第二步:两边都在整图里补发一次,同口径对账

单算子计时有个硬伤:它把算子拎出来单独跑,没有邻居跟它抢资源,也没有和别的算子并发重叠。拿这个数去跟我自己引擎整图里的时间比,口径就歪了。

所以真正的对账我换了一种测法,两边用同一种:

某类算子的图内时间 ≈ T(这一类在原位置多发一次) − T(基线)

意思是在完整的推理过程里,把某一类算子原地再执行一遍,前后各加一道同步栅栏,看总时间多了多少。多出来的就是这类算子在真实环境里的耗时。补发时把输出改写到一块草稿内存上,真实结果逐位不变。

这个测法也要验,我做了三道:

  • 全部算子一起补发,增量 605.4 ms,基线 605.3 ms,对上了
  • 各类分别补发再相加,606.8 ms,也对上了
  • 同一类补发 1、2、4 次,每次增量都是 162 ms,线性,说明补发本身没引入别的开销

对账表出来,差值从大到小:

类 我的引擎 ms llama.cpp ms 差
dense 投影 252.2 188.3 +63.9
投影前后的转置 25.3 0 +25.3
注意力 19.1 5.8 +13.3(口径不同,见第 6 章)
线性注意力递推 53.5 55.5 −2.0
MoE 专家 245.9 270.1 −24.2
整图 605.3 553.5 +51.8

MoE 那行是负的。我比它快 24 毫秒。

差距的大头在 dense 投影(attention 前后那几个普通矩阵乘)和一个 llama.cpp 根本没有的步骤:我在投影前后要把数据转置一遍,它直接按原布局吃进去,这一步凭空多出 25.3 ms。

这里还有个能自己算的判据,用来判断一个矩阵乘是不是卡在搬数据上:

如果卡带宽:耗时 ∝ 要读的权重字节数
llama.cpp 的 dense 权重是 8.5 bit,我的是 4.5 bit
字节比 = 4.5 ÷ 8.5 = 0.53
卡带宽的话,我应该大约 188.3 × 0.53 ≈ 100 ms
实测 252.2 ms

读的字节少了将近一半,时间反而多了 34%。那就别在带宽上找原因了,问题在计算或反量化(把 4bit 压缩权重还原成能算的数)上。这个估算很粗,188.3 里还混着一小块 F32 的路由权重,但方向差得太远,粗一点不影响判断。

顺便抓到一个丢人的事:上一轮我的 verifier agent 写过一个按 kernel 计时的探针,漏掉了最大的那个 kernel,155.9 ms。原因是那个 kernel 先绑数据、后设管线,而设管线这一步会把探针记的绑定清空,它就从记录里消失了。整整一轮的分 kernel 计时都缺着最大的一块,我也没发现。

4. 单算子跑分为什么把 MoE 少算了 60%

回到第 2 章那个 95.7%。

llama.cpp 那个单算子计时工具,给 MoE 算子生成测试输入时,每个 token 选的专家都是 0 到 7 号打乱。于是 512 个 token 只碰到 8 个专家,每次只读 4.5 MiB 权重。真实推理里,每个 token 从 256 个里挑 8 个,512 个 token 几乎会把全部专家碰一遍,每次要读 144 MiB。

改一下专家分布重测,单次耗时:

gate/up:只用 8 个专家 1447 µs → 256 选 8 随机 2315 µs(+60%)
down:   只用 8 个专家 1577 µs → 256 选 8 随机 2391 µs(+52%)
MoE 两类合计:174.9 ms → 280.9 ms
覆盖率:95.7% → 114.9%

覆盖率直接冲过 100%。因为另一个方向的误差也在:整图执行时,小算子会被融合或跟别的算子重叠,单独测再相加会偏高。一个偏低一个偏高,碰巧抵消在 95.7%。

所以看任何人拿单算子跑分评 MoE kernel,先问一句:测试输入里的专家分布是什么样的? 只碰几个专家的测法,权重全在缓存里,量出来的是一个真实推理里不会出现的速度。

计时工具本身也有坑。我顺手在 M1 上试了几种「给单个 GPU kernel 计时」的办法,结论是这台机器的公开接口只能在一整批任务的边界打时间戳。想拿单个 kernel 的时间,就得把它拆成独立的一批,可拆完它就不能跟别人并发了:

两个 kernel 放进同一个并发批次:21.00 ms(几乎完全重叠)
两个 kernel 串行:               39.59 ms
拆成两个独立批次:               37.82 ms

拆开测,重叠就没了,测出来的是一个比真实更慢的世界。系统自带的性能分析器给的逐 shader 时间线是采样推算的,一个 kernel 单独跑 6 次,它只记到 4 段共 0.67 ms,漏得很厉害。看看谁和谁重叠还行,拿来裁决快慢不行。

5. 照着账打,9.9% 是从哪来的

对账给出的方向是:dense 投影、转置、调度,MoE 和线性注意力这轮不碰。六个 agent 又跑了 8 小时,合进来的东西逐项是:

  • 换 dense 投影的分块形状,顺手让投影直接写成下游要的布局、删掉几处没用的转置:约 +5.2%
  • 最后一层只写 KV 缓存:最后一层的 q 投影、注意力、输出投影、MoE 算出来都没人用,只有它写进缓存的 K/V 后面要用。llama.cpp 早就这么干了:约 +2.3%
  • 剩下约 110 处转置改成分块写,内存写入能合并:约 +1%
  • 第一层算完就先提交给 GPU、embedding 并行填充等几个零碎:各约 0.5%
基线中位数 833.85 → 冠军 916.32 tok/s,+9.9%
对 llama.cpp:916.32 ÷ 927.9 = 0.988

最反直觉的一条在这轮的复盘里:照抄 llama.cpp 的矩阵乘分块形状,全部更慢,-1.8% 到 -2.4%。我又算了一下自己最大那个 dense kernel 的实际算力:

970 GFLOP ÷ 155.9 ms ≈ 6.22 TFLOPS
llama.cpp 同类算子单独测:约 6.2–6.4 TFLOPS

持平。也就是说,那个 kernel 本身从头到尾就不比人家差。对账表上「dense 慢 34%」,真正的来源是转置、死工作和 CPU 没跟 GPU 重叠起来这些 kernel 外面的东西。

我是先对完账才知道该打 dense,打完 dense 才知道 dense kernel 本身没问题。一层一层剥,每一层都在推翻上一层的直觉。

6. 这次哪些数不够硬

  • 补发时前后各加一道栅栏,这一类算子就没法再跟邻居并发了。所以每类的图内时间是带并发惩罚的上界。llama.cpp 默认开并发,这个惩罚对它更大,dense 那 +63.9 ms 只可能低估。
  • llama.cpp 里的加法、归一化这类小算子补发之后整图反而变快(1078 tok/s),说明它们有原地写或融合,补发会改结果,这几类我没取数。
  • 注意力那行两边口径不同:我的 19.1 ms 含了 q/k 的归一化、旋转位置编码和拆分,llama.cpp 的 5.8 ms 只有 flash attention 本体。那 13 ms 不能全算到注意力上。
  • 量化格式不对等:llama.cpp 这个文件里 dense 是 8bit,路由专家才是 4bit;我全是 4bit。这是两个文件的对账,不是同位宽的 kernel 比赛。
  • llama.cpp 的 dense 图内补发是 188.3 ms,它单独测的算子加起来约 231 ms,差了 40 多毫秒。单算子测法可能高估了它,没查明。
  • 「去掉栅栏、让投影并发」这条:llama.cpp 关掉并发会慢 2.5%,所以我以为我这边也有这么多可拿。实测中性。我的推测是每个节点要么大到自己就能吃满 GPU,要么小到不到 0.05 ms,没什么可藏的,但没有 GPU 时间线证据。
  • 我在第 4 章跑那几个计时探针的时候,同一台 M1 上 agent 的自动评测还在跑。我污染了 3 次判定,其中一次把一个好版本判成了退步。后来给评测加了噪声门才把这段时间的数清掉。反过来,第 4 章那组 21.00 / 39.59 / 37.82 也是在评测还在跑的时候测的,绝对毫秒数被干扰过,我只信同一次运行里的相对比例。第 2、3 章的对账是在没有评测跑的时段测的(常驻服务没停)。

7. 两个引擎差 N%,按这个顺序查

  1. 先停手。在没跟对手同口径对过账之前,别再按自己引擎内部的占比定方向。
  2. 导出对手的计算图,在真实图里数每类算子的次数,拿模型结构反查一遍。
  3. 算覆盖率。接近 100% 也别急着信,看看有没有两个方向相反的误差在抵消。
  4. MoE 算子的单算子跑分,先查测试输入的专家分布。只碰几个专家的结果直接作废。
  5. 两边用同一种测法拿图内时间:原位置补发一次减基线。用「全部补发 ≈ 基线」和「补发 1/2/4 次线性」验测法本身。
  6. 差值从大到小排,只打排第一第二的。对手比你慢的那几类,这一轮谁都别碰。
  7. 对手某类比你快时,先按字节算一下:你读得更少还更慢,就别查带宽了。
  8. 照抄对手 kernel 形状之前,先算你自己那个 kernel 的 TFLOPS。打平了就去找 kernel 外面的东西:转置、没人用的计算、CPU 和 GPU 之间的空档。
  9. 你的探针在测,别的评测就别在同一台机器上跑。