我在笔记里写过这么一行:「四并发,每流 86–98 tok/s,聚合约 350 tok/s,continuous batching 真有效。」
第二天摘下服务做隔离复测,四条等长请求同时发出,完成时刻分别是 5.5 秒、11.1 秒、16.8 秒、22.5 秒。四条请求一条接一条排队,聚合吞吐 91.2 tok/s。
350 和 91,差 3.8 倍。中间没有任何配置改动。差的只是我算账的方法。
想直接拿判据的看第 2 章,它是一张完成时间表加一个除法;想知道我怎么被同一条日志骗了两次,看第 3 章。
1. 350 是怎么凭空长出来的
环境很简单:一台 M1 Max 64GB 笔记本,跑一个 35B 参数、每次激活 3B 的 MoE 模型(MoE 就是模型里分成很多个「专家」,每个 token 只叫醒其中几个干活,所以 35B 的体积、3B 的算量)。引擎是一个刚出的闭源 Mac 推理引擎,宣传点之一就是支持 continuous batching——把多条请求的 decode 步拼在一起算,一次读权重服务好几条请求,这是服务器端吞吐能上去的全部秘密。
我第一轮测法是:四个请求并发打过去,每个请求的响应里带着「本请求 decode 速度」,四个数分别是 86 到 98。都成功了,都不慢,那不就是四条一起跑吗?把四个数一加,350 左右。
问题出在「本请求 decode 速度」这个数量的是什么。它量的是这条请求从吐第一个字到吐最后一个字的速率。如果四条请求在排队,每条轮到自己的时候,独占整台机器,自然能打出满速 98。四条各自都是 98,加起来 392,看着像四路并行,其实一个人在跑四趟接力。
四条全部 200,没有一条报错,没有一条超时。成功率证明排队没炸,证明不了引擎在并行。 这一课我记得很牢,因为我拿着这个错数字,第二天就在盘算「这台笔记本能多扛几个后台任务」。
2. 判据:完成时间要么是阶梯,要么是一团
把测法改掉,只改两处:所有请求等长(greedy 采样,强制 512 个 token,不许提前停),记录每条请求的完成时刻,它自报的速率一个字不看。
N=1 完成时刻 5.51s 聚合 93.0 tok/s
N=2 完成时刻 5.4 / 11.0s 聚合 93.4 tok/s
N=4 完成时刻 5.5 / 11.1 / 16.8 / 22.5s 聚合 91.2 tok/s
聚合吞吐就是一个除法:
聚合吞吐 = N × 每条 token 数 ÷ 最后一条完成的时刻
N=4: 4 × 512 ÷ 22.5s = 91.0 tok/s
这个数不需要任何引擎配合,不读日志,不信响应体里的自报速度,拿秒表就能量。
然后看形状。真并行的完成时刻应该挤成一团:四条差不多同时开始、差不多同时结束,总时长比单条略长(每步多读一点 KV 缓存),聚合吞吐随 N 往上涨。排队的完成时刻是等距阶梯,步长正好等于单条耗时,聚合吞吐在任何 N 下都是一条平线。
上面那张表,步长 5.5 秒左右,跟 N=1 的 5.51 秒一模一样。平线,阶梯,串行。没有第二种解释。
顺带说一句为什么 N=4 的聚合(91.2)比 N=1(93.0)还低了 2%:排队本身有开销,每条请求切换时要重建状态。串行不光没赚,还倒贴一点。
3. 引擎其实早就告诉我了,我两次都没看
去翻服务的启动日志。有一行 WARNING 大意是:这个模型是 Gated-DeltaNet 混合架构,循环状态只能维护一条序列,continuous batching 对它不可用。
也就是说我那个 --continuous-batching 4 的 flag,引擎收下了,然后在启动时默默拒绝了。flag 被接受和功能真的开了,是两件事。我看到 flag 没报错就以为开了。
更丢人的是十来天后引擎出了新版,release notes 里写了「hybrid 模型支持 continuous batching」。我很高兴,装上,重测:
C=1 C=2 C=4 C=8 单条速率
旧版 默认 90.3 89.5 89.6 88.5 ~98 恒定
新版 默认 87.5 89.3 88.7 88.3 ~98 恒定
新版 开 CB=8 86.7 87.2 86.8 86.3 ~95 恒定
三组全是平线。再翻日志,WARNING 换了措辞:新版的 continuous batching 只覆盖稠密的混合架构,混合架构再加 MoE 的组合还没验证过,不支持。我这个模型恰好就是混合加 MoE。
而且开了 flag 反而慢 2%——多了一层分页 KV 的管理开销,然后照样退回串行。一个功能开关,接受了、没报错、还让性能掉了一点,唯一能揭穿它的地方是一行 WARNING。 这行日志我两次都没先读。
第二次的教训比第一次值钱:release notes 说「支持 X」,你的模型属不属于那个 X 的范围,得自己验,方法就是第 2 章那张完成时间表。
4. 单流 98 到底算快还是慢?一个除法
排队的事说清楚了,还剩一个问题:既然并发吃不到,那单流 98 tok/s 是不是还有很大空间可挖?我当时的计划是自己写一个专用引擎,目标 125 tok/s。
这个问题也能算,只要两个数:每生成一个 token 要从内存读多少字节,以及这台机器实际能提供多少内存带宽。decode 阶段几乎全是在搬权重,算力大部分时间闲着,所以速度上限就是「搬砖速度 ÷ 每块砖多重」。
第一个数不能拍脑袋。我逐层审计了这份 4bit 权重文件的字节数:把 token embedding 排除(那是查表,读一行),把用不到的预测头排除,每 token 真实读取 1.849 GB。我原先的计划里写的是 1.6 GB,偏低 13%,因为没算 MoE 路由那 8 个专家的字节。
第二个数更容易骗人。这台机器标称 400 GB/s,但那是理论值。我写了个 Metal 上的纯读流探针实测:
缓冲区 1–4 GiB 358–366 GB/s
缓冲区 8 GiB 316 GB/s
缓冲区 16 GiB 267 GB/s
工作集越大带宽越低,页表和 TLB 的开销是真的。模型驻留 21 GiB,诚实的分母该取 265–315 GB/s,用 366 是自欺。
现在代进去:
达成带宽 = 97.83 tok/s × 1.849 GB/token = 181 GB/s
利用率 = 181 ÷ (265 ~ 315) = 57% ~ 68%
MoE 模型的 decode 因为专家访问是稀疏的、不连续的,现实里能吃到的带宽一般在 60–65% 这一带。98 tok/s 已经坐在天花板上。我原来那个 125 的目标,倒推需要 231 GB/s,也就是 73–87% 的带宽利用率,在 MoE decode 上物理不成立。那个目标建立在 1.6 GB/token 和 330 GB/s 两个乐观数字上,一个偏低一个偏高,叠起来就是一个够不着的靶子。
我给那个自研引擎项目预注册过一条 kill gate:现有引擎的带宽利用率超过 55% 就关单。实测 57–68%,触发,关掉。这个除法花了一天,省了六到八周。有点肉疼,但账摆在那儿,能赢的空间是个位数百分比。
顺带把「新引擎比 llama.cpp 快 2.36 倍」这个数也拆了:llama.cpp 那份动态量化权重每 token 读 2.80 GB(它把注意力层和输出头抬到了更高精度),新引擎读 1.849 GB,光字节数就少 1.52 倍;kernel 带宽利用率 181 对 116 GB/s,又是 1.56 倍。1.52 × 1.56 = 2.37。没有魔法,全是这两项。
5. 拆穿别人的并发数字
看到任何一份「N 并发聚合多少 tok/s」,先问三句:
第一,你的聚合是怎么算的? 如果是把每条请求自报的速率加起来,直接作废。请求不重叠时,这种加法能把串行吹成 N 倍并行。要看的是 N × 每条 token 数 ÷ 最后一条完成时刻。
第二,完成时刻是阶梯还是一团? 给我四条请求的结束时间戳。等距阶梯、步长等于单条耗时,就是排队。这一条不需要任何专业知识,看四个数就行。
第三,聚合吞吐随 N 涨不涨? 平线是串行的确诊签名。真的 batching 至少在 N=1 到 N=4 之间会涨,涨幅取决于模型和 KV 缓存的大小,但不会是平的。
再加一句给自己的:flag 被接受不等于功能开了,去读启动日志。我的两次翻车都能用这一句预防。
同一套方法拿去问 prefix cache 也一样。我后来测同一个 6000 token 的 prompt 连发三次,首字延迟恒定 6.8 秒,缓存计数恒为 0。那个 --prefix-cache flag 也是接受了、没报错、没生效。同根同源。
6. 这套测法的缺陷
诚实起见列一下:
- 单机单模型。 所有数字来自一台 M1 Max 64GB 和一个 35B-A3B 的 4bit 量化。换硬件、换模型,绝对值全变,但第 2 章的阶梯判据和第 4 章的除法不变。
- 1.849 GB/token 是审计权重文件算出来的,等于假设每步激活的 8 个专家全部要从内存读。 实际上 M1 Max 有一块系统级缓存,热专家有可能被吸收一部分,真实读量可能略低于 1.849,那样利用率会比 57–68% 更高,结论只会更保守。
- 带宽分母是我自己写的探针测的,只测了纯读流,没测读写混合。取 265–315 这个区间就是为了把这个不确定性包进去。
- decode 速度随上下文长度衰减,这里全是短上下文。 同一台机器上我测过一个 43k token 的 prompt,decode 掉到 34 tok/s。第 4 章的 98 是短上下文的天花板,长对话拿不到。
- 闭源引擎的 warning 措辞我是转述的,原文在两个版本之间改过,我没有逐字引用。
7. 下次测并发,按这个顺序
- 请求等长,greedy,强制固定输出长度。不然「快的先完成」会把阶梯抹成一团。
- 记每条请求的完成时刻,别信响应体里的自报速率。
- 聚合吞吐 = N × 每条 token 数 ÷ 最后一条完成时刻。只用这个数。
- 扫 N=1、2、4、8,看聚合吞吐是平线还是往上走。平线就是排队。
- 完成时刻等距、步长等于单条耗时,确诊串行,不用再找别的解释。
- 每一个性能 flag 加上去之后,去启动日志里 grep WARNING,确认它没被拒绝。
- 想知道单流还有多少空间:先审计每 token 读多少字节,再用大缓冲区实测带宽,两者相除,跟实测速度对账。利用率超过 55% 就别想着自己写引擎了。