三个引擎,同一台基础款 M5,同一个 4-bit Qwen3.5 9B,同一个提示词。结果是这样的:
| 引擎 | 解码速度 |
|---|---|
| llama.cpp(Q4_K_S) | 22.0 tok/s |
| MLX(4-bit) | 25.1 tok/s |
| uzu(Mirai-M 4-bit) | 92.1 tok/s |
模型没换,机器没换,最快的比最慢的快四倍多。这个差距值得拆开看,因为它不是「谁优化得好」这么简单。
29 tok/s 是一道算术题
生成每个 token,模型都要完整前向一次,而这一次要把全部权重从内存读一遍。所以解码速度首先受内存带宽限制,不是受算力限制。
基础款 M5 的内存带宽约 153 GB/s。4-bit 的 9B 模型权重大约 5.2 GB。153 ÷ 5.2 ≈ 29。也就是说,一次只出一个 token 的话,理论上限就在 29 tok/s 附近。
llama.cpp 的 22 和 MLX 的 25 都贴着这条线。它们没有做得差,是这条路本身只剩这么宽。真实可达带宽通常还低于标称值,29 已经算乐观。
92 tok/s 没有违反物理
如果每次前向仍然读一遍 5.2 GB 权重,92 tok/s 意味着每秒读 478 GB,远超带宽。矛盾出在「每次前向只出一个 token」这个前提上。
uzu 的做法是让一次前向产出多个 token。实测平均每次 7.5 个,普通解码是 1 个。反过来算:92.1 ÷ 7.5 ≈ 12.3 次前向每秒,乘 5.2 GB,约 64 GB/s。不但没超带宽,反而比 llama.cpp 的 22 次前向(约 114 GB/s)更省。
同样的权重读取次数,干了更多活。
一次前向,四个动作
- 1起草一个小模型一次猜出后面 16 个位置的候选
- 2拼树5670 万参数的适配器从左到右筛选,保留几条备选分支
- 3验证9B 主模型一次前向检查整棵树
- 4取舍保留最长的一条一致路径
起草模型叫 DFlash,负责广撒网;中间那个 5670 万参数的适配器叫 Weaver,负责把散落的候选拼成读得通的文本,并保留分支;最后由 9B 一次验证。被接受的 token 都经过主模型确认,所以输出仍然是主模型会给的结果,而不是小模型的猜测。
这也是推测解码和「换个小模型」的根本区别。后者是降级,前者只是把主模型的计算摊薄。
质量能不能守住
关键在验证规则。标准做法下,主模型逐 token 检查候选,凡是不认可的都会被丢掉,最终输出与它自己解码一致。采样模式则要实现正确的接受与拒绝概率,输出分布才不会偏。
所以「4 倍」是否可信,不该看演示视频,而该看两件事:候选被接受的比例,以及被拒绝后回退的逻辑。前者决定速度,后者决定质量。
4 倍不是通用数字
7.5 这个平均值是在代码类负载上测的。代码、数学、结构化输出的可预测性高,草稿容易命中;开放式闲聊、冷门领域文本的接受率会掉,提速随之缩水。温度调高,拒绝的候选更多,差距还会再小一点。
三个引擎的 4-bit 并不是同一套配方。llama.cpp 用的是 Q4_K_S,uzu 用的是自己的非对称 4-bit 检查点,项目方给出的口径是两者在体积和精度上接近。要严格对比,得把量化、提示词长度、输出长度都对齐。
这次测的是解码,不是预填充。长提示词第一次读入的时间没有变。重复提示词可以靠前缀缓存省掉大部分预填充,但首读的长上下文仍然要等。
模型支持是逐个加的。每个模型都需要配套的检查点和起草模型,由维护者提交。现在能跑的模型列表不会太长。
还有平台限制:它是 Rust 写的,Metal 内核,只面向 Apple 芯片,提供 Swift、Python、TypeScript、Rust 绑定,能嵌进 Mac 和 iOS 应用。手上一台 N 卡机器的话,这些数字与你无关。
换不换,看四个条件
- 机器是 Apple 芯片,日常主力是 9B 上下的模型
- 负载以代码、结构化输出为主,而不是开放式写作
- 能接受模型列表慢慢补,不急着今天就覆盖所有模型
- 愿意用同一量化、同一提示词、同一输出长度自己复测
四条都占,值得试。只占一两条,先等等。尤其是日常跑 27B Q8 或者长上下文首读的人,这次的数字不能直接套。27B 在 M5 Max 上的说法是 4.4 倍,配置不同,同样得自己测。
结论
这次真正有意思的不是 92.1,而是 29 tok/s 那道天花板被绕开了。模型参数没变,硬件没变,变的是「读一次权重能换回几个 token」。
本地推理的选型问题也跟着变了。以前问哪个模型分高,现在还得问:同一台机器上,哪个运行时能让这个模型真正跑起来。下次看到类似的提速数字,先核对三件事:量化是否对齐、负载是否可预测、每次前向出了几个 token。