返回博客

2026.10.07

冷进程跑 979,连续跑只剩 850:我说跟对手「基本打平」,为什么其实落后 15%?

我在 M1 Max 上写的推理引擎,4096 token 预填充冷启动 979 tok/s,对手 984,我当晚说了句「基本打平」。一小时后发现对手那个 984 是连续跑出来的,它歇一下再单发是 1017–1053;而我连续跑会掉到 850 左右。拆下来是整机约 60W 的功耗墙:我的程序在内存那一路多花 5.6W,GPU 就少分 5.5W,频率被压低 9%。这篇讲一条能自己算的换算(热态 ≈ 冷态 × 频率比)、怎么识别一个跑分是冷是热,以及为什么切段省字节没用、删掉重复读才有用。

推理引擎性能方法论Apple Silicon

那天晚上 23:15,我给自己写的推理引擎跑完一轮复测,回了一句:「1K、2K 领先,4K 基本打平。」

4096 个 token 的 prompt,我的引擎 979 tok/s,对手 984。差 0.5%,说打平不过分。

一个小时后我把这句话收回了。对手那个 984 是同一个进程里连着跑出来的,让它歇一下再单发,是 1017–1053。我那个 979 正好反过来,是新起进程的第一发,连着跑我会掉到 850 左右。

冷对冷,我落后 4–7%。热对热,落后约 15%。我拿自己最好的那一发,去比人家被压过的那几发,凭空比出一个「打平」。

更气人的是,整个过程里系统一直报告温度正常。

想直接拿换算公式的看第 3 章;想知道为什么「少读点内存」这条路走不通,看第 5 章。

1. 先确认它真的会掉

背景一句:这是我在 M1 Max(64GB,插着电,没开低电量模式)上给 Qwen3.6-35B-A3B 写的单模型推理引擎。下面全是预填充(prefill),就是模型先把整段 prompt 读一遍、还没开始往外吐字的那个阶段。速度就一个除法:

tok/s = 4096 ÷ 预填充耗时(秒)
4180 ms → 980 tok/s
4850 ms → 845 tok/s

同一个进程,同一条 prompt,连发 4 次:

980 → 939 → 880 → 881
980 → 848 → 840 → 843   (另一轮)

我第一反应是进程里残留了什么状态,比如上一次的 KV 缓存没清干净。三个对照把它排除了:

  • 倒过来跑,不管哪条 prompt 打头,第一条总在 980 左右
  • 上下文窗口改成 16384,照样掉
  • 每条单独起一个进程,全在 978–982

所以掉速只跟「这是连着的第几发」有关,跟跑什么内容、进程里攒了什么都没关系。

再试插空档。两发之间歇 3 秒、15 秒、45 秒:

歇 3s / 15s / 45s:都稳在 931–936
不歇:            掉到 840–880

歇 3 秒和歇 45 秒没区别。这个形状很像有一笔预算:花完了,要么歇一下拿回一部分,要么一直被压着。

2. 温度正常,频率在掉

我用 mactop 每 250 毫秒采一次。第一发 4096 时 GPU 跑满 1296 MHz,功耗读数约 41W。连着跑起来,频率降到 1000–1130 MHz,功耗读数 22–32W。

同一段时间里,系统的 thermal_state 一直是「正常」,pmset 也没记过任何温度或性能警告。

说白了:系统说一切正常,GPU 正在被降频。要是只看系统温度状态判断有没有降频,这次会完美漏掉。

然后把对手拉进来同条件比。两边都先热机,再在同一进程里连续跑 6 次 4096,取后 70% 的采样算中位数:

对手(BaseRT 0.2.0) 我的引擎
连续跑 4096 993 / 1012 第 1 发 980,之后 841–866
GPU 频率 1218–1246 MHz 1109–1123 MHz
GPU 功耗 37–38 W 约 32 W
内存带宽 约 29 GB/s 49–51 GB/s
系统其余部分功耗 18.8 W 24.4 W
整机 约 60 W 约 60 W

两边整机都停在 60W 左右。这台机器插着电,整机像是有一道 60W 的墙。

墙是死的,钱只能这么分:

GPU 能分到的 ≈ 60W − 内存/互连那一路 − 一点零头
对手:37.5 + 18.8 = 56.3
我:  32   + 24.4 = 56.4

零头两边都是 3.6W 左右。我的程序在内存那一路多花了 24.4 − 18.8 = 5.6W,GPU 就少拿了 37.5 − 32 = 5.5W。一瓦换一瓦。GPU 少拿钱,频率就低:1116 对 1232,低 9%。

我每秒从内存搬的数据比对手多 70%。冷启动时 GPU 频率是满的,多出来的这些流量藏得严严实实;一撞墙,它就直接折成了频率。

3. 一条能自己算的换算:热态 ≈ 冷态 × 频率比

一段程序要跑的 GPU 周期数如果是固定的,它的速度就跟频率成正比。我后来专门做过周期归因,结论是热态变慢完全来自降频,周期数没变。那就可以这么算:

热态速度 ≈ 冷态速度 × (热态频率 ÷ 冷态频率)

代进去对账:

我的旧版:980  × 1116 ÷ 1296 = 844   实测 841–866
我的新版:1048 × 1162 ÷ 1296 = 940   实测 946
对手:    1040 × 1232 ÷ 1296 = 989   实测 993–1012

前两行误差在 1% 以内。对手那行差 0.4–2.3%,因为我没测过它冷态时的频率,直接假设它也跑满 1296,冷态速度也只取了三次单发的中位数 1040。

这条换算好用在可以反着用:手上只有冷启动的跑分,再看一眼持续负载时的频率,就能估出热态会掉多少,不用真跑一遍长测。反过来,要是算出来跟实测差很远,说明慢下来的不止频率,周期数也变了,那是另一种病,得换个方向查。

4. 看到一个跑分,先问它是第几发

回到那句「打平」。两边的测量本身都没错,错在我把两种不同的东西放在了一起比。

对手自带的跑分工具会连着跑好几次再报平均,报出来的天然偏热。我那天的复测是每条单独起进程,报出来的天然是冷的。两个数一凑,偏差的方向正好都帮我:我这边是最好的一发,它那边是被墙压过的几发。

那天上午我还做过一份 13 个引擎的全量对比表,里面我自己的 4096 只有 742,对手是 984,两个都是连着跑出来的。那张表我当时拿来定了下一轮的方向,口径一直没标。

所以看任何笔记本、一体机、小主机上的推理跑分,先问三句:

  • 是新进程第一发,还是同一进程连着跑?连着跑的话,取的是第几次之后?
  • 两次之间歇了多久?
  • 跟它比的那个数,是不是同一种口径?

在这台机器上,一个「冷启动第一发」的数字能比连续跑高 15% 左右。拿它去比别人的连续数,结论能从「落后 15%」变成「打平」。我就是这么干的。

回头看,这道墙前一天就露过脸。我发现长 prompt 在同一个进程里排在别的测试后面,会慢 4–15%,而且随顺序变;同一个版本自己跟自己比,能判出 −2.34%。当时的修法是让每个长测试单独起进程,噪声降到 ±0.15%。那次修掉的多半就是这道墙,只是我当时没往这上面想,事后也没单独验证。

还有个更早的反例。7 月我在同一台机器上查过解码(decode,模型一个一个往外吐 token 的阶段)为什么越跑越慢,当时也怀疑过功耗墙。那次数据方向是反的:我的程序功耗 20W,比对手的 25W 还低,速度也低 20%,撞墙的话应该是高的那个被压。所以那次排除了功耗墙。同一台机器,换个负载,答案就翻了。

5. 省字节没用,删重复读才有用

诊断完我的第一反应很直接:内存流量太大,那就少读点。

第一招是把长 prompt 切成几段,让中间结果小到能留在芯片自带的系统级缓存(SLC,一块所有单元共用的大缓存)里,不用跑出去碰内存:

每段长度 每次预填充读写量 热态速度
4096(不切) 241 GB 基准
2048 191 GB −1.1%
1024 204 GB −4.8%
512 242 GB −11.3%

切成 2048 一段,读写量少了 21%,速度反而慢了 1.1%。切得越碎越慢,因为每段都有一笔固定开销。

后来真正管用的是另一类改动:找到同一块数据被好几个计算单元各自从内存读一遍的地方,改成只读一次,大家共用。

  • 矩阵乘里换了分块的遍历顺序,让激活值那一块不再被反复读。每次预填充的读写量 238 → 175 GB,内存那一路功耗 24.2 → 23.1W,频率 1100 → 1140 MHz,速度 +3.1%
  • 注意力里,同一组 K/V 原来被 8 个 query 头各读一遍,改成每组只搬一次进片上共享内存,8 个头从那儿取。这一块的热态耗时 753 ms / 34 GB → 395 ms / 约 9 GB,速度 +7.5%

两种改动都在减少读写量,为什么切段不行、删重复读就行?我的解读是:功耗那一路记的是数据离开 GPU 自己的二级缓存之后的全部流量,命中 SLC 的也算。切段只是把流量从内存挪到了 SLC,这一路没怎么省;删掉重复读,那些流量压根就不发生了。这个解释跟数据对得上,但我手上没有能单独量 SLC 流量的计数器,只能算推断。

还有一个饱和点:每次预填充的读写量降到 165 GB 左右以下,再省字节频率就不涨了。往后只能去省 GPU 周期本身。

合起来:

热态 4096:848 → 946(+11.5%)
冷态 4096:979 → 1048
内存带宽:约 50 → 32–33 GB/s
GPU 频率:约 1120 → 1158–1166 MHz
内存那一路:24.4 → 22.0–22.2 W
GPU:       31.5 → 34.4 W
整机:      不变,约 59.7 W

墙一点没动,钱从内存那一路挪给了 GPU。对手热态 993–1012,我从约 0.85 倍追到约 0.94 倍,还差 6%。改之前我每次预填充实测约 243 GB,对手整个预填充约 119 GB,这笔账还没算完。

6. 这次哪些数不够硬

  • 只有一台机器、一个模型、插电状态。60W 这道墙是我从两边整机功耗都停在 60W 左右推出来的,没找到官方文档,也没在电池供电或别的 M 系列机器上验过。
  • 功耗和频率全是 mactop 的软件读数,250 毫秒一采,没上外接功率计。
  • 对手的热态数是它自带工具跑的,我的是自己的测试脚本,两边计时口径不完全一样。第 3 章对手那行的冷态频率是假设的。
  • 「冷态」本身也不唯一。新起进程的第一发能到 980,可歇 45 秒只回到 933。我猜是新进程加载权重那几秒 GPU 歇得更彻底,没验证。
  • 连续跑的日志里有两发离群:一发 14.8 秒,一发 8.9 秒,同时解码也慢了好几倍。原因没查。它们不影响中位数,但说明这台机器上还有别的东西会突然插一脚。
  • 「取后 70% 采样的中位数」「同进程 6 发取后 3 发」都是我定的口径,换种取法数字会动一点。
  • 第 5 章「切段只是把流量挪进 SLC」是推断,见上。

7. 在笔记本上测推理速度,按这个顺序来

  1. 先定口径:你关心第一发,还是一个常驻进程连着处理请求?是后者就测连续跑,扔掉第一发。
  2. 同一进程连发 5 次以上,看第 2 次往后掉没掉。掉了就有墙,别再拿第一发说事。
  3. 测的时候同时采 GPU 频率和功耗。别信系统的温度状态,它这回全程报正常。
  4. 用「热态 ≈ 冷态 × 频率比」对一下账。对得上,慢的只是频率;对不上,去查周期数。
  5. 跟别人比之前,确认两个数是同一种口径:第几发、歇多久、用谁的工具。
  6. 长测试单独起进程,或者先做固定的热机再计时,别让测试顺序混进结果。
  7. 想在墙底下提速,先找「同一块数据被读了好几遍」的地方。切段、换精度这类省字节的招,先算算流量是不是只换了个地方。
  8. 上次排除过的假说,换个负载重新查一遍。