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