M5 跑 9B 模型上限 29 tok/s,Uzu 靠一趟 8 个 token 跑到了 92。

M5 跑 9B 模型上限 29 tok/s,Uzu 靠一趟 8 个 token 跑到了 92。

摘要 Akshay Pachaar 的长文解释了本地大模型为什么慢,以及 Mirai 的开源推理引擎 Uzu 怎么提速。单路生成时,每出一个 token 都要把全部权重从内存读一遍,速度被内存带宽封顶:base M5 上 4-bit 的 Qwen3.5 9B 约 29 tok/s,llama.cpp 和 MLX 实测是 22 和 25。Uzu 用推测解码让主模型一趟确认多个 token,一次起草 16 个,每趟平均落地约 8 个,同一台机器跑到 92 到 117 tok/s。成绩限于 M5 系列芯片和 Mirai 发布的检查点,并且随内容类型变化。

2023 年 8 月,Andrej Karpathy 发过一条长帖,解释 llama.cpp 为什么能在笔记本上跑大模型。里面有句很形象的话:只生成一路文本时,芯片上的计算单元在干等,权重像是“用一根吸管从内存里吸过来的”。两周后他又发了一条,介绍绕开这个瓶颈的办法,叫推测解码(speculative decoding),原话是“this one weird trick”。

三年过去,这个瓶颈还在。Daily Dose of Data Science 的联合创始人 Akshay Pachaar 10 月 8 日发了一篇长文,在一台 16 GB 内存的 base M5 MacBook Pro 上算了笔账:4-bit 量化的 Qwen3.5 9B,每秒最多出 29 个 token,llama.cpp 和 MLX 实测是 22 和 25。同一台机器、同一个模型,Mirai 开源的推理引擎 Uzu 跑出了 92 到 117。

内存并没有变快。下面按这篇长文的顺序,看 29 这个上限怎么算出来,Uzu 用哪三项设计绕过去,以及这组数字在什么条件下成立。

内存带宽:base M5 跑 4-bit Qwen3.5 9B,上限约 29 tok/s

大模型推理分两个阶段。Prefill 读提示词,所有 token 可以并行处理;Decode 写回答,每出一个 token,都要把模型完整算一趟。本地跑模型觉得慢,慢的是 Decode。

单路生成时,每出一个 token 都要把权重从内存读一遍;量化减少每趟读取的字节,但仍是一趟一个 token

单路生成(batch size 1)时,一趟计算只推进一条序列。每出一个 token,芯片要把全部权重从内存读进 GPU 一遍,每个权重大致只做一次乘加。算得快,读得慢,GPU 常常算完了手头的,下一块权重还没送到。速度于是由内存带宽决定,和算力关系不大。

云端没有这个烦恼:服务器可以把很多用户的请求拼成一批,读一遍权重同时推进许多条序列。本地设备上只有一条序列,拼不了批。

这就有了一个用除法能算出来的上限。base M5 的内存带宽是 153 GB/s,4-bit 的 Qwen3.5 9B 约 5.2 GB:

153 ÷ 5.2 ≈ 29 token/秒

GPU 再快也过不了这条线。实际的引擎每趟还要读历史 token 的缓存等数据,会再低一些。作者在这台机器上用同一条提示词实测,llama.cpp 是 22.0 tok/s,MLX 是 25.1 tok/s。

量化(把权重存成更少的位数)是最常见的优化。它让每趟要读的字节变少,上限随之抬高,但循环没变,还是一趟一个 token。4-bit 量化已经是本地推理的标配,再想快,得让一趟计算产出不止一个 token。

Speculative decoding:草稿模型先猜,主模型一趟验证多个 token

推测解码在主模型旁边加一个小的草稿模型(draft model)。草稿模型便宜地往前猜几个 token,主模型把这几个位置放进同一趟里验证,接受其中连续正确的前缀,从第一个不认可的位置接着往下写。

普通解码每个 token 要跑一趟主模型;推测解码由草稿模型先猜,主模型一趟验证多个

举个例子:草稿给出 5 个 token,主模型认可前 4 个。这一趟让序列前进了 5 个 token,其中 4 个是被接受的草稿,第 5 个是主模型自己在那个位置给出的。普通解码要跑 5 趟。

最终输出仍然由主模型决定。按原文的说法,只要接受规则正确,输出的概率分布和主模型逐个生成时一致,草稿模型只影响速度。

一趟验证多个位置,比只算一个 token 费算力。这笔买卖在本地划算,靠的是上一节那个事实:算力本来就闲着。推测解码把闲着的算力花出去,换来更少的读权重趟数。这个办法出自 Google 和 DeepMind 研究者的论文,Karpathy 那条帖子引的就是它们。

代价在接受率上。草稿越长,一趟要验证的越多,前面错一个、后面全部作废的机会也越多。所以常见的做法都保守:MTP(multi-token prediction,训练时加进模型里的几个额外预测头)一次只猜 3 到 4 个 token,llama.cpp 的 MTP 模式用的就是它。

Uzu 在 M5 上把草稿预算开到 16 到 32 个 token,文中测试的 Qwen3.5 9B 用的是 16。要让这么长的草稿不白猜,它在起草、验证和数值格式上各做了一项设计。

DFlash+Weaver:16 个位置各猜各的,怎么连成一句话

长草稿要起得快,Uzu 用的是并行草稿模型 DFlash:一次算出后面每个位置各自最可能的候选。

并行的毛病是各猜各的。每个位置都看得到已有的上下文,却看不到前面几个位置选了什么。原文的例子:提示以“She went to the store to buy”结尾,四个位置可能分别选出 a、few、of、milk,单看都合理,连起来是“a few of milk”。第二个词就对不上,后面全部作废。草稿越长,这样的机会越多,接受率跟着往下掉。

DFlash 给每个位置出候选名单,Weaver 按顺序挑选并保留多条路径,主模型验证整棵树

Weaver 是补这个缺口的小模型,出自论文《Trees from Marginals》,论文里的版本有 5,670 万参数。DFlash 先给每个位置出一份候选短名单,Weaver 再按顺序从名单里挑,每挑一个都参考前面已经挑定的,于是得到“a gallon of milk”。它只在短名单里选,不在整个词表上做投影。

Weaver 还能同时留下几条说得通的路径,比如另一条“a bottle of juice”,合起来是一棵草稿树。主模型验证整棵树,接受其中最长的那条正确路径。树的形状随内容变:代码这类好猜的内容,树又长又窄;开放式写作,树短而分叉多。

论文报告的成绩是比逐 token 解码快 4.37 倍,比优化过的 DFlash 基线再高 24.7%。原文特意注明,这是在 CUDA 和 SGLang 上做的研究实验,不能当作 Uzu 的成绩。还有一点原文没有提:论文的四位作者都在 Mirai 那篇实现说明的署名里,这是同一个团队的工作。

Rollback-free 验证:Gated DeltaNet 的状态只提交一次

草稿树带来一个新问题,出在 Qwen 的模型结构上。注意力层给每个历史 token 留一份缓存,只增不改。Qwen3.5 和 Qwen3.6 里另有一种 Gated DeltaNet 层,它把看过的内容压进一个固定大小的状态,每接受一个 token,状态就被改写一次。Qwen3.5 9B 的 32 层里,24 层是这种层。

草稿树的每条分支都会把状态改成不同的样子。验证一条分支时如果直接改了状态,验下一条之前就得先恢复原状。树一大,反复保存、恢复的内存操作和重复计算,会把推测解码省下的时间吃回去。

上:每验证一条分支都要保存、恢复状态;下:Uzu 一次验证整棵树,选定路径后只提交一次状态

Uzu 把“验证”和“提交状态”分开。验证时,一次算出整棵树上每个候选需要的值,不动正在使用的状态;主模型选定路径后,只对被接受的那几个 token 提交状态更新。Mirai 的实现说明把它叫作 rollback-free tree verification,由专门为 Metal 写的 GPU 内核完成。没有错误的更新,也就没有什么需要回滚。

int8 与 Hadamard 变换:数值格式跟着 M5 的硬件走

第三项设计在数值格式上。Mirai 给同一个模型发布两档检查点:Mirai-M 用 4-bit 非对称整数量化,体积小;Mirai-L 用 8-bit 对称量化,精度高。两档都是整数格式,这是有意的选择。

原因在 M5 的硬件。和更早的 Apple GPU 不同,M5 在 int8(8 位整数)精度下算术吞吐最高,它的每个 GPU 核心里都有一个叫 Neural Accelerator 的矩阵单元。Uzu 在运行时把激活值(层与层之间传递的中间结果)也量化成 8 位,矩阵乘法两边都是整数,验证这一步就能走硬件加速的 int8 通路。原文配图引的是 Mirai 的数字:这条通路的矩阵乘法吞吐是浮点通路的 2 倍。

这种矩阵单元要矩阵够大才划算。一次验证 16 个候选,等于给它 16 行的活;一趟只算 1 个 token,就只有 1 行。长草稿和整数格式是配套设计的。

精度低了,舍入误差会变大。分组量化时,一组数值共用一个缩放比例,这个比例要照顾组里绝对值最大的那个;组里一旦有个离群的大值,其余小值能用的整数档位就很少。Uzu 在量化前做一次块对角的随机 Hadamard 变换(block-diagonal Random Hadamard Transform):把每 32 个值混在一起,一个大的离群值被摊成许多中等大小的值。变换完全可逆,除了舍入之外不改变模型算出的结果。32 这个块大小对应 Apple GPU 内核的 SIMD 宽度,一组线程正好处理一块。

实测:base M5 上 22.0 → 34.7 → 92.1 tok/s

作者在那台 base M5、16 GB 内存的 MacBook Pro 上,用同一个 Qwen3.5 9B、同一条提示词“Write a python function to merge two sorted lists”跑了三种配置,都是 4-bit 权重。

同一台 base M5、同一个 Qwen3.5 9B:不开推测解码、llama.cpp 的 MTP(起草 3 个 token)、Uzu 的 DFlash+Weaver

不开推测解码的 llama.cpp 是 22.0 tok/s;打开 MTP、一次起草 3 个 token,是 34.7;Uzu 是 92.1。作者说多次运行 Uzu,速度在 92 到 117 之间。

Uzu 的命令行会打印每趟前向计算平均产出几个 token。普通解码这个数恒为 1.00;文中贴出的一次运行是 8.39,对应生成速度 98.10 tok/s,多次运行在 7.5 到 9.6 之间。

把这两个数相除,能看出时间花在了哪:98.10 ÷ 8.39 ≈ 每秒 11.7 趟,而 llama.cpp 逐 token 解码是每秒 22 趟。按这个推算,Uzu 的一趟(含起草和验证)耗时接近普通解码的两倍,但一趟落地 8 个 token。内存带宽的限制原封未动,变的是每读一遍权重的产出。

更大的模型上,原文引的是 Mirai 自己公布的测试:Qwen3.6 27B 的 Mirai-M 检查点(15.6 GB),机器是 M5 Max、128 GB 内存,9 月 16 日测得,原文注明未经独立复现。Uzu 开推测解码是 114 tok/s,MTPLX 55,llama.cpp 30,MLX 26;Uzu 不开推测解码是 35.54。也就是说,不开推测时 Uzu 只比 llama.cpp 快不到两成,那 3.2 倍来自推测解码。

Mirai 公布的 Qwen3.6 27B 生成速度,按题型分:代码、数学、对话

按题型拆开,差别很明显:数学题 130.72 tok/s,代码题 115.80,日常对话 95.48。代码和数学里可预测的续写多,对话留给草稿模型的把握少。原文的总结是,推测解码的速度是工作负载的属性,不是模型的固定参数。

这组数字已经有了新版本。Mirai 的模型页在 10 月 11 日换成了 21 个数据集的混合测试,M5 Max 上的推测解码成绩变成:Uzu 116.42,MTPLX 58.02,oMLX 48.74,llama.cpp 39.00。新版里 llama.cpp 用的是带 MTP 头的模型文件,它逐 token 解码的成绩是 30.55,和旧版的 30 接近。照新版算,Uzu 对 llama.cpp 的领先是 3.0 倍,原文引用的旧版是 3.8 倍。

这组数字怎么用

上限自己能算。 内存带宽除以模型文件大小,就是逐 token 解码的上限。挑本地模型和机器时,这个除法比看芯片算力管用:同一台机器上,模型文件大一倍,上限就低一半。

只有 M5 系列能开推测解码。 文中这份检查点的推测配置只列了 M5、M5 Pro、M5 Max。在 M1 到 M4 的 Mac 上,同一条命令不带草稿模型,每趟仍是 1.00 个 token。模型也得用 Mirai 发布的检查点;Mirai 9 月初发布这项功能时,说的是先支持 Qwen3.6 27B。

比的是整条引擎。 三家用的量化文件不一样:Uzu 是 Mirai-M,MLX 是自己的 4-bit 转换,llama.cpp 是 Q4_K_S。Mirai 的命令行没有关掉推测解码的开关,所以这组对比分不出多少来自 DFlash+Weaver、多少来自量化和内核,原文自己也这样说明。最接近同引擎对照的,是 llama.cpp 开和不开 MTP 那一组:22.0 对 34.7。

速度之外要看质量。 原文的图表只量速度。Mirai 的模型页另外给了量化质量的对比,指标是和未量化模型输出分布的 KL 散度,越低越接近原模型:在 Qwen3.6 27B 上,Mirai-M 是 0.012,llama.cpp 的 Q4_K_S 是 0.011,MLX 的 4-bit 是 0.034。这是 Mirai 用自有数据集测的自报结果。

测试提示词是最有利的一类。 base M5 上的 92 到 117,来自一条写 Python 函数的短提示词,约 20 个 token。长上下文、聊天类内容、机器发热之后的速度,原文都没有测。它的结论里也提醒:要用自己产品里的真实请求来测,并且同时量质量。原文后半部分给了完整的复测命令和测试纪律,整理在文末附录。

回到开头那根吸管。吸管没有变粗,Uzu 做的是每吸一口多落地几个 token。在 base M5 上用 Qwen3.5 9B 写一段代码,它是 llama.cpp 的 4 倍左右;换芯片、换模型、换成聊天,要自己测过才知道。

附录|在自己的 Mac 上复测:原文给的命令和测试纪律

以下命令和输出照录自原文,供手边有 M5 机器的读者自己验证;正文第 6 节的数字就是这样测出来的。

一条命令看速度。 Mirai 的命令行通过 Homebrew 安装,第一次运行会下载模型:

brew install mirai

mirai --model trymirai/Qwen3.5-9B-M \
  -m "Write a python function to merge two sorted lists"

原文环境是 Mirai CLI 0.6.1、macOS 26.6.2、base M5、16 GB 内存。回答结束后打印的统计(原文贴出的一次运行,节选):

time to first token: 0.12 s
prefill speed: 161.11 t/s
generation speed: 98.10 t/s
tokens per forward pass: 8.39 t/f
memory used: 5.77 GB
total energy: 114.90 J
duration: 7.36 s

读数时看两行:generation speed 是 Decode 阶段的速度,tokens per forward pass 是主模型每趟平均产出的 token 数,普通解码恒为 1.00。prefill speed 在这里参考意义不大,提示词只有约 20 个 token。

三个引擎用同一个客户端比。 Uzu、MLX LM 和 llama.cpp 都能起一个 OpenAI 兼容的本地服务,分别占 8000、8001、8002 端口:

mirai server --model trymirai/Qwen3.5-9B-M --port 8000 --no-prefix-cache

mlx_lm.server --model mlx-community/Qwen3.5-9B-4bit --port 8001

llama-server -hf unsloth/Qwen3.5-9B-MTP-GGUF:Q4_K_S \
  --alias qwen3.5-9b-q4-k-s -ngl 99 -fa on -c 8192 -np 1 --port 8002

几个参数的用意:

  • --no-prefix-cache:Uzu 默认会缓存处理过的提示词前缀,重复请求能跳过大部分 Prefill;关掉它,每次请求才做全量计算。
  • -c 8192:不加的话 llama.cpp 会按模型的 262K 上下文预留内存,16 GB 的机器放不下。
  • -np 1:只开一个并发槽位。要打开 llama.cpp 的 MTP 起草,再加 --spec-type draft-mtp --spec-draft-n-max 3。

原文另给了一个只用 Python 标准库的测速脚本:固定同一条提示词,temperature 设为 0,最多输出 1,024 个 token,关闭思考,流式接收,记录首 token 时间、总时长和 Decode 速度。

原文的测试纪律:

  • 一次只开一个服务,内存里只留一份模型,不让两个引擎抢内存带宽。
  • 每个引擎跑两次,第一次含预热,丢掉,记第二次。
  • 固定机器和电源模式、提示词和对话模板、输出 token 上限、采样设置、预热次数和测量次数。
  • 客户端测到的速度包含 HTTP 流式传输,会比引擎自己日志里的略低;客户端的数字只和客户端的数字比。
15 万创意人的 AI 账本:近半数说赚多了,扣掉对照组只多 12%。 2026-10-10

评论区