本机实测 M2 Max 可持续内存带宽 348 GB/s,模型权重 15.13 GB,由此算出的 decode 理论上限是 23.0 tok/s。绕过 oMLX 直接用 MLX 跑同一个模型,实测 22.2 tok/s —— 相当于已经吃掉了 96% 的可用带宽。oMLX 报的 17.5–20 tok/s 只是叠加了调度与采样开销。
所以真正的选择只有三个:减小权重体积、一次读盘服务多个请求、一次读盘吐多个 token。 第三条的 MTP 你已经验证过基本无效,本文给出原因和剩下两条的实测数字。
LLM 解码的本质非常朴素:每生成一个 token,就要把全部权重从内存读一遍。于是
decode 上限 (tok/s) = 内存带宽 (GB/s) ÷ 权重体积 (GB)
我用 MLX 在 GPU 上做了大矩阵 matvec 微基准,得到这台机器的真实带宽(不是标称值):
| 访存模式 | 实测带宽 | 说明 |
|---|---|---|
| bf16 大矩阵 matvec | 348.8 GB/s | 接近峰值,代表机器上限 |
| 大数组归约(STREAM 式) | 337.8 GB/s | 纯读带宽,互相印证 |
| 4bit 量化 matvec(248320×5120) | 325.9 GB/s | decode 真实 kernel |
| 4bit 量化 matvec(5120×5120) | 39.0 GB/s | 小矩阵严重受 launch 开销限制 |
注:M2 Max 标称 400 GB/s,实测可持续约 348 GB/s。上面最后一行也解释了为什么「层数多、每层小」的模型 在 MLX 上跑不满带宽——小矩阵根本喂不饱 38 个 GPU 核。
我把 4bit 模型逐层重量化成 3bit / 2bit,同模型同数据做对比。速度用 MLX 裸跑测, 质量用同一段中英混合文本的困惑度(PPL,越低越好)衡量。
| 位宽 | 权重 | decode tok/s | 提速 | 有效带宽 | PPL | 质量代价 |
|---|---|---|---|---|---|---|
| 4bit(官方现用) | 15.13 GB | 22.00 | 1.00× | 333 GB/s (96%) | 4.813 | 基准 |
| 3bit(本次重量化) | 11.93 GB | 25.72 | 1.17× | 307 GB/s (88%) | 5.715 | PPL +19% |
| 2bit(本次重量化) | 8.73 GB | 31.73 | 1.44× | 277 GB/s (80%) | 16.871 | PPL +250%(崩坏) |
降低位宽提升的是单请求延迟。但如果你的推荐任务是一次给多个候选项打分/批量生成, 那么把请求并发起来,一次读盘服务多个序列,总吞吐可以数倍提升——而且完全不损失质量。
| 并发数 | 总生成吞吐 | 相对 1× | 说明 |
|---|---|---|---|
| 1 | 21.5 tok/s | 1.00× | 基线 |
| 2 | 34.3 tok/s | 1.59× | |
| 4 | 33.6 tok/s | 1.56× | |
| 16 | 50.9 tok/s | 2.37× | 本次测得的最高吞吐 |
上表用 mlx-lm 的静态 batch 测得,噪声偏大(8× 处有回退)。oMLX 自己用的是 continuous batching + mlx-lm BatchGenerator,官方在 M3 Ultra 上给出 8× 并发 3.36–4.14× 的数字, 实际效果应好于这里的静态 batch。单请求延迟不会变快,但跑完一批的总时间会大幅缩短。
你观察到「推测 4 个 token 和不用草稿一样快」,这不是你的错觉,源码层面有三条硬约束:
config declares mtp heads but checkpoint ships no mtp.* weights,
所以 oMLX 原生的 Lightning MTP(mtp_enabled)永远无法启用,
只能走外部草稿模型的 vlm_mtp_enabled 路径。Qwen3.8-27B-MTP-4bit 的
block_size=3,而 mlx-vlm 只有在最近若干轮接受率 ≥65% 时才允许放大,
否则一律夹回 3 —— 每轮实际只多产出约 2.31 个 token。你手动设 4 / 5 基本不生效。synchronize + clear_cache。
反应到数字上:一轮耗时约为单步的 2.12 倍,却只多吐 2.31 个 token。用你自己的日志做同 prompt 严格对比:不开 MTP 19.0 tok/s,开了 20.7 tok/s, 只有 +9%。而且它在并发时会整条请求回退到普通 BatchGenerator。
~/.omlx/model_settings.json 里针对该模型有一批开关,当前全是 false。
我把它们都翻了一遍源码,结论是大部分只作用于 prefill,对你的 decode 一点用没有:
| 开关 | 作用阶段 | 能提速 decode? | 判定依据 |
|---|---|---|---|
turboquant_kv_enabled(KV 4bit) | decode | 几乎不能 | 见下方上下文扫描:4k 上下文收益约 2% |
vlm_mtp_enabled | decode | +9% | 与 turboquant_kv 互斥,二选一 |
qwen35_ane_prefill_enabled | prefill | 不能 | 准入要求输入恰好 2048 行,decode 是 1 行,永不命中 |
qwen35_oq_a8_enabled | prefill | 不能 | 需要 M5+ 原生 INT8 张量单元,M2 Max 被 API 直接拒绝 |
specprefill_enabled | prefill | 不能 | 需另配草稿模型;且会丢 token,掉质量 |
dflash_enabled | decode | 无法启用 | 模型目录里没有 dflash 草稿权重 |
qwen4_gdn_decode_wide_proj | decode | 不适用 | 只对 Qwen4-Exp 生效,你是 qwen3_5 |
moe_expert_offload_enabled | — | 不适用 | 这是 dense 模型,没有专家层 |
| prompt 长度 | KV 体积 | decode tok/s | ms/token |
|---|---|---|---|
| 32 | 2.1 MB | 22.23 | 45.0 |
| 512 | 33.6 MB | 22.18 | 45.1 |
| 2048 | 134.2 MB | 21.46 | 46.6 |
| 4096 | 268.4 MB | 21.53 | 46.4 |
上下文从 32 涨到 4096,decode 只掉了 3%。原因是这个模型是混合架构: 64 层里 48 层是 GDN 线性注意力(状态大小固定,不随上下文增长),只有 16 层是 full attention。 每 token 的 KV 仅 64 KiB,4k 上下文也才 268 MB,相对 15.13 GB 的权重读取完全可以忽略。 你实际平均 prompt 约 1.7k token,开 KV 量化的收益不到 2%,还要承担有损压缩的风险——不建议。
| 检查项 | 结果 |
|---|---|
| Ollama 后台是否抢带宽 | 否,/api/ps 返回空,未加载任何模型 |
| 内存压力 | 系统空闲 93%,无 swap 颠簸 |
| SSD KV 缓存 | 已启用,4.17 GB,只影响多轮前缀复用 |
| oMLX 相对裸跑的开销 | 22.2 → 17.5–20 tok/s,约 9–20%(调度 + 采样 + 流式) |
~/.cache/huggingface/hub/local--Qwen3.8-27B-3bit-gs64(11.93 GB,25.7 tok/s)prism-ml/Ternary-Bonsai-2-27B-mlx-2bit
(8.85 GB,官方称保留 98.2% 基准分),注意它可能需要配套 kernel 才能跑满。enable_thinking 关掉,
能减少大量无谓的生成 token(不改变 tok/s,但显著缩短端到端等待)。把上面 1 + 2 组合起来:单请求 25.7 tok/s,批量吞吐 60+ tok/s。 想要更高,只能换更小的模型或更高带宽的机器——M2 Max 的 348 GB/s 是硬约束, 任何软件优化都不可能让它凭空变出带宽。