返回博客

2026.09.27

批量从 2 开到 16,吞吐只从 65.7 涨到 67.4:批处理到底在省什么?

我让自研推理引擎一次同时解 16 条序列,16 条输出和各自单跑逐位一致,聚合吞吐却是一条平线;同一台机器上 MLX 翻了一倍。两个月前我还用一本字节账论证这条路是死路,后来实测把账本三条全推翻:批处理的收益主要来自把 B=1 时闲着的带宽喂饱,省下的字节是次要的。这篇给一个两项相乘的公式,用来在动手前估批处理能拿多少,以及三条拆别人批处理数字的判据。

推理引擎性能方法论Apple Silicon

同一台 M1 Max,同一个 35B 的 MoE 模型,我的自研引擎一次同时解 B 条序列:

B=2    65.7 tok/s
B=4    67.2
B=12   67.3
B=16   67.4

16 条序列的输出,和它们各自单独跑出来的结果逐位一致。正确性满分,吞吐一条平线。

旁边用 MLX 跑同一个模型,B=1 是 65.7,B=16 是 132.8,两倍出头。

更尴尬的是,两个月前我写过一份文档,用一本很整齐的字节账证明「这个模型上批处理不值得做」。后来那本账被我自己的实测三条全推翻。这篇就讲那本账错在哪,以及它怎么帮我一眼看懂上面那条平线。想直接拿公式的看第 3 章;想知道怎么拆别人的批处理数字,看第 5 章。

1. 先说我原来那本账

背景:我在给 Qwen3.6-35B-A3B 写一个只服务这一个模型的推理引擎。这个模型是 MoE,里面有 256 个「专家」,每个 token 只叫醒 8 个干活;另外 40 层里有 30 层是一种线性注意力(Gated DeltaNet),每条序列要背着一份自己的递推状态走。

decode 阶段(一个字一个字往外吐的阶段)几乎全在从内存搬权重,算力大部分时间闲着。所以批处理的经典理由是:权重读一遍,服务 B 条序列,每个 token 分摊到的字节变成 1/B。

我当时按这个思路算了三类字节:

  • 普通投影、输出头:B 条序列共享,总量不变。这是收益来源。
  • MoE 专家:B 条序列各自叫醒不同的专家,B=16 时期望会碰到约 102 个专家,读取量膨胀 12.7 倍。
  • 线性注意力的递推状态:每条序列一份,B 多大就搬多少份,线性膨胀。

然后我又拿一个探针实测了批量矩阵乘,结论是 B≥2 寄存器压力暴涨、B=16 每个 token 反而慢 15%。

三条凑一起叫「三个杀手」,结论写得很硬:前两个是模型结构决定的,改 kernel 也救不了。

同一时期,一个现成的闭源 Mac 推理引擎在启动日志里也对这个模型拒绝开批处理,理由是「批量 MoE 加 Gated-DeltaNet 这条路径还没验证」。我当时把它当成了旁证:你看,人家也撞墙了。

三个杀手,后来一个都没站住。

2. 三个杀手是怎么倒的

第一个倒的是那个寄存器探针。它把 4-bit 权重解包写错了:一处 nibble 没右移,一处偏移量按错的单位加。它一直没暴露,因为它只做「B=1 对比 B=n」自己跟自己比,两边用同一个错的解包,当然一致。自比只能证明没改坏,证明不了算对了。

换成一个带独立 CPU fp32 参考的新探针,9 个配置全部对拍通过,重测 B=16:

杀手         我按字节账的判断         实测 B=16 每 token 加速
MoE 覆盖率   天花板约 1.26 倍          3.59 倍
递推状态     线性膨胀,没收益          10.34 倍
寄存器       B=16 反而慢 15%           3.35 倍(加权)

MoE 那条最有意思。覆盖率膨胀是真的发生了:B=16 实测碰到 98 个专家,跟我蒙特卡洛估的 102 差不多。按字节算,它最多只能快 1.26 倍。结果实测 3.59,超了快三倍。

账本本身没错,错的是它默认了一个前提:B=1 的时候,带宽已经跑满了。 实际上根本没跑满。

3. 公式:批处理的加速 = 字节比 × 带宽比

我用的机器,内存带宽实测天花板是 367 GB/s(用 128 MB 以上的缓冲区测;小缓冲区会命中缓存,我测出过 646 这种假数)。拿几个算子 B=1 时的实测带宽除一下:

线性注意力递推状态   27.6 GB/s    7.5%
MoE 专家            58.7 GB/s   16.0%

B=1 的时候,这台 GPU 大部分时间在等,不在搬。一条序列提供的活太少,喂不饱它。

所以批处理的收益要拆成两项相乘:

每 token 加速 = 字节比 × 带宽比
字节比 = B=1 每 token 读的字节 ÷ B 条时每 token 读的字节
带宽比 = B 条时实测带宽 ÷ B=1 实测带宽   (上限 = 367 ÷ B=1 实测带宽)

这就是一个除法再乘一个除法。代进去对账:

递推状态:每条序列一份,字节比 = 1.00
          带宽 27.6 → 285.3 GB/s,带宽比 10.34
          1.00 × 10.34 = 10.34,实测 10.34

MoE 专家:B=1  0.1607 ms × 58.7 GB/s ≈ 9.4 MB/token
          B=16 0.716 ms × 161.4 GB/s ≈ 115.6 MB,÷16 ≈ 7.2 MB/token
          字节比 ≈ 1.30,带宽比 161.4 ÷ 58.7 = 2.75
          1.30 × 2.75 ≈ 3.59,实测 3.59

两项都对上了。我原来那本账只算了第一项。MoE 的字节比只有 1.3 左右,跟我当时估的 1.26 差不多,这点我算对了;我没算的是后面那个 2.75。

反过来也能用。输出头那一层(把隐状态映射到 24 万词表的大矩阵)B=1 已经跑到 264.3 GB/s,占天花板 72%。带宽比最多 367 ÷ 264.3 = 1.39;我那个实现又是每条序列各读一遍权重,字节比是 1。所以它最多快 1.39 倍。实测 1.23,B 从 2 加到 16 一直不涨。

我当时挑它做第一个整图切片,理由是它没有状态、最好改。挑的时候只看了它耗时占多少,没看它 B=1 已经跑到多满。它本来就喂饱了,批处理没东西可喂。

一句话判据:B=1 的带宽利用率越低,批处理越值钱。 一个算子 B=1 就跑到七成,你批再多也就那样。

4. 回头看那条平线

有了这个公式,开头那条平线就好读了。换成每 token 耗时:

B=2    1000 ÷ 65.7 = 15.2 ms/token
B=16   1000 ÷ 67.4 = 14.8 ms/token

B 翻了 8 倍,每个 token 的成本几乎没动。字节比和带宽比两项都是 1,才会是这个形状。

翻代码就对上了。这一版只把「数据通路」做成了多序列:每条序列有自己的递推状态、自己那片 KV 缓存。但每一层计算时,仍然是一条序列调一次,每个投影、每个专家还是单 token 的矩阵向量乘,权重读了 B 遍。形式上是批处理,GPU 看到的活跟 B=1 一模一样,只是排了 B 趟。

这一版比单流服务模式的 88.6 还慢一截。那是因为服务模式有 CPU 和 GPU 的流水重叠,这条路径没有。

我给这一期写过一条闸门:B=16 聚合吞吐必须超过 MLX 的约 132,同时 B=1 不许退化。超不过就停。平线一出来就停了,后面计划写的线性注意力和注意力多序列 kernel 没做,因为瓶颈压根不在它们身上。

说实话有点庆幸那条闸门。没有它,我大概会顺着计划把 kernel 写完,然后才发现白干。

5. 拆穿别人的批处理数字

看到一份「批处理快了 X 倍」或者「批处理没用」,先问三句。

第一,他看的是每 token 时间,还是 GB/s? 我在这里摔过一次。把输出头放进批量路径后,报出来的带宽从 264 一路掉到 20 GB/s,我当场判定「完全串行」。后来测另一个小矩阵,真正的批量 kernel 带宽同样从 37 掉到 7.7 GB/s,每 token 却快了 3.35 倍。原因是那个 GB/s 的分子是固定的权重字节,B 变大、总时间变长,比值必然往下掉。分子固定的比值,拿来判断并发,方向会反。 只看每 token 摊销时间随 B 怎么变。

第二,这是微基准还是整图? 微基准只测裸 kernel,整图要走完整路径,还有每条序列各自重做、共享不了的那部分。我自己两次被坑:输出头拿同类矩阵微基准的 4.96 倍去外推,整图实测 1.23;一个共享专家微基准 2.80 倍,整图约 1.7,打了六折。问一句:那个块在整图里 B=1 的带宽利用率是多少?高于六七成,微基准的倍数基本兑现不了。

第三,「不支持」是谁说的? 那行「还没验证」的日志,只说明那个引擎自己没做。同一台机器上 MLX 的 batch_generate 跑同一个模型,B=16 是两倍出头。别人家的 WARNING、白名单、「不支持」,只能算嫌疑,不能算墙。我把它当成墙的一根支柱,写进文档放了两个月,直到回头给每条「做不了」标证据来源才发现它是借来的。

6. 这次哪些数不够硬

  • 开头那条平线是粗口径:吞吐里含输出头和 CPU 上的 argmax,上下文长度只开到 4096。形状可信,绝对值别拿去比。
  • 10.34、3.59、3.35 这几个倍数来自探针合成数据的微基准,不是整图。尤其递推状态那个探针是我写的朴素版,B=1 只铺了 32 个线程组,天生欠载;引擎里现役的 kernel 铺了 4096 个线程组,B=1 本来就快得多,批处理的真实收益肯定没有 10 倍。10.34 证明的是机制,数额另算。
  • 公式也有不贴的时候:同一轮微基准里,2048 行和 512 行的投影大致对得上(带宽比 4.61、3.60,实测 4.96、3.48,差 7% 以内),8192 行那个对不上:带宽 148.2 → 259.0 GB/s,带宽比 1.75,按天花板算上限也才 2.48,实测却是 2.62。那个 kernel 每条序列各读一遍权重,字节比理应是 1,多出来的部分我没拆清楚,可能是缓存吸收了一部分重复读取。
  • 我自己的整图估算约 157 tok/s(B=16)没有实测过,是按各块微基准加权推出来的。之前一版同样方法推出过 328,被整图数据当场打回。157 我也不信,等真批量 kernel 落地再说。
  • 测量顺序踩过坑:同一个配置,顺序跑两次分别得到 1.64 倍和 2.05 倍,这台机器的动态频率让先跑的档位吃亏。后来改成同进程内所有档位严格交替 7 轮取中位才稳住,离散度仍有 14–32%。
  • MLX 的数字是它自己统计的生成吞吐,每档 3 轮,256 个 token,8 种长文 prompt 轮换。跟我引擎的口径不完全相同,只当外部参照。
  • 单机单模型:全部来自一台 M1 Max 64GB 和一个 4-bit 的 35B-A3B。换卡换模型,数字全变,公式不变。

7. 下次想上批处理,按这个顺序算

  1. 先量每个大算子 B=1 的实测带宽,分母用大缓冲区实测的天花板,别用标称值。
  2. 算带宽比上限:天花板 ÷ B=1 带宽。低于 1.5 的算子别指望批处理。
  3. 再算字节比:共享的权重除以 B,MoE 按覆盖率膨胀,每条序列独占的状态不摊。
  4. 两项相乘,就是这个算子批处理的收益估计。按整图耗时加权之前,先按利用率从低到高排改造顺序,别按耗时占比排。
  5. 判断批处理有没有生效,只看每 token 摊销时间随 B 的曲线。每 token 时间不随 B 下降,就是没批上,不管数据通路做得多漂亮。
  6. 任何微基准倍数进整图前打个问号,尤其是 B=1 已经跑到六七成的块。
  7. 先算内存放不放得下:这个模型每 token 的 KV 缓存约 45 KB,按 256K 上下文满配,一条序列 11.8 GB,B=4 就装不下。要么降上下文,要么上分页 KV(我实测间接寻址的代价 0.1–0.9%)。
  8. 给自己写一条闸门:B=16 聚合吞吐超过现成实现,B=1 不退化。过不了线就停。