图片展示的是一个黑底白苹果标志,下方文字为“4x Faster LLMs on Apple Silicon”。该图片与文档中关于Apple M5芯片加速LLM运行的内容相关,可能是对文档中提到的“基础版Apple M5芯片每秒移动153 GB数据,最高可达每秒约29个token”的苹果芯片加速LLM运行效果的直观呈现,强调了在Apple Silicon架构上LLM运行速度的提升。

2023 年,Karpathy 解释了为什么本地 LLM 运行缓慢。当模型在你自己的电脑上生成一段文本时,芯片的大部分时间都在等待权重从内存中到达,而计算单元则处于闲置状态。

图片展示了本地LLM推理的两种模式:托管推理和设备上推理。托管推理中,多个应用(App A、B、C)向共享推理服务器发送请求,服务器处理请求并返回结果,可批量处理请求。设备上推理则每个设备运行自己的模型,每个设备有一个活跃序列,不跨设备批量,每个应用与Uzu +本地模型交互。该图与上下文紧密相关,直观呈现了Karpathy提到的本地LLM运行模式,帮助理解芯片在不同模式下的数据处理情况。

几周后,他描述了绕过这一问题的方法。一次遍历中检查多个 token 的成本与检查一个 token 大致相同,因此一个小的草稿模型可以提前猜测,主模型可以一起验证这些猜测。

第一个观点至今仍设定着天花板。基础版 Apple M5 芯片每秒移动 153 GB 数据,而 4 位 Qwen3.5 9B 模型大约重 5.2 GB,因此在每次遍历生成一个 token 的情况下,该芯片最高可达每秒约 29 个 token。

llama.cpp 和 MLX 都正好落在这条线之下,在同一个编程提示词上分别达到每秒 22 和 25 个 token。

第二个观点才是发挥空间所在,但像 llama.cpp 的 MTP 模式这样的本地方案每次只能草拟三到四个 token。

来自 Mirai 的开源推理引擎 Uzu 在 Apple Silicon 上把这种方法推得更远。它最多提前草拟 16 个 token,保持这些猜测彼此连贯,并在为 M5 编写的内核上一次遍历中验证它们。

图片展示了Uzu Inference Engine的README页面。页面顶部有“README”和“MIT license”标签,右上角有编辑和菜单图标。中间大标题为“UZU INFEERENCE ENGINE”,下方介绍Uzu是一款高性能AI模型推理引擎,可直接在应用中部署AI,零延迟、全数据隐私且无推理成本。其关键特性包括简单高阶API、统一模型配置、可追踪计算以确保正确性及在Apple设备上使用统一内存。底部还提到广泛支持多种模型。

在同一台 M5、同一模型和同一提示词下,Uzu 写入了每秒 92 到 117 个 token。

让我们来梳理一下天花板的来源、Uzu 如何突破它,然后用一个可以指向你自己提示词的基准客户端,将其与 llama.cpp 和 MLX 进行对比测试。

Why batch-one decoding is memory-bound(为什么批大小为 1 的解码受内存限制)

LLM 推理有两个不同的阶段。

图片展示了预填充和解码阶段的处理流程。左侧预填充阶段,模型并行处理提示词token,提示词状态准备好;右侧解码阶段,每次推进一个序列,每次前向传播生成一个新token。图片与上下文紧密相关,直观呈现了为什么批大小为1的预填充阶段并行处理提示词token,而解码阶段逐个生成响应token的原因,即预填充阶段处理提示词整体运算量小,而解码阶段每次推进一个序列,每次前向传播只推进一个序列,整体运算量大。
  • 预填充阶段并行处理提示词 token。
  • 解码阶段逐个生成响应 token。

解码是相关的阶段,每个新 token 都需要一次额外的模型遍历。在遍历期间,运行时读取权重块并计算下一个 token 的分数。

在批大小为 1 时,一次前向传播只推进一个序列。

相对于移动的权重数据量,整体运算量很小。为了产生一个 token,芯片将模型的每个权重从内存一次性流式加载到 GPU 中,并对每个权重做大约一次乘加运算。

GPU 可以在内存带来下一个权重块之前完成这些计算。因此计算速度往往受限于内存带宽。

这就给出了一个硬性上限。基础版 M5 每秒从内存移动 153 GB 数据。4 位 Qwen3.5 9B 检查点大约重 5.2 GB。如果每次遍历产生一个 token,那么该芯片上模型每秒最多只能写出 153 ÷ 5.2 ≈ 29 个 token,无论 GPU 计算多快。

实际引擎的表现略低于这个上限,因为每次遍历还会读取其他数据,例如早期 token 的缓存。

量化是改进这一点的标准技术,通过使用更小的权重来减少每次遍历读取的字节数。

图片展示了批大小为1时的模型运行流程。模型权重存储在内存中,每次生成一个token需进行一次模型遍历,读取权重块,计算下一个token得分,生成一个token。量化虽减少每次遍历读取的字节数,但并未改变每次生成一个token需进行一次模型遍历这一循环。图片与上下文紧密相关,直观呈现了量化技术在批大小为1时对模型运行流程的优化情况。

然而,量化仍然优化的是同一个单 token 循环。主模型必须为每个生成的 token 再次运行。

事实上,量化已经相当普遍,所以现在让我们来理解是什么让 Uzu 作为推理引擎与众不同。

How Uzu optimizes bytes and tokens per pass(Uzu 如何优化每次遍历的字节数和 token 数)

Uzu 针对解码循环中的两个量进行优化。它减少每次遍历传输的字节数,并增加每次遍历被接受的 token 数。

图片展示了Uzu优化字节和token数量的两个方面。上方为量化处理,减少模型权重传输字节数,以更少位存储权重,需更少内存流量,留出更多统一内存;下方为投机解码处理,每次模型遍历仅一个token,通过推测验证多个候选token,若多个被批准,序列可前进多个token。图片与上下文紧密相关,直观呈现了Uzu针对解码循环中字节和token量的优化策略。
  • 量化处理第一个量。它以更少的位存储模型权重。更小的检查点需要更少的内存流量,并为上下文和应用程序状态留出更多统一内存。
  • 投机解码处理第二个量。它一起验证多个候选 token。如果多个候选被批准,序列就会前进多个 token。

许多本地引擎已经支持 4 位权重。Uzu 的区别在于围绕该格式的硬件路径。

Mirai 为同一个基础模型发布了多个优化的检查点。它按大小层级标记它们。M 表示 Medium(中等),而 L 表示 Large(大型)。

这些标签描述的是检查点,而不是不同的模型架构。

图片展示了Mirai模型的两种优化检查点Mirai-M和Mirai-L。Mirai-M为Medium检查点,使用4位非对称整数量化,占用较小内存,每个权重4位;Mirai-L为Large检查点,使用8位对称量化,每个权重8位,提供更高精度。两者基于同一基础模型,Mirai发布多个优化检查点,但检查点选择不是新架构。图片与上下文紧密相关,直观呈现了文档中提到的Mirai模型优化检查点的量化方式及特点。
  • Medium 检查点使用 4 位非对称整数量化。
  • Large 检查点使用 8 位对称量化。

Medium 偏向更小的内存占用,而 Large 为每个权重保留更多位。Mirai 的 **量化详解** 描述了它如何将训练后量化与量化感知蒸馏结合起来。

使用整数格式是刻意为之,因为它同时有利于硬件和压缩。

更具体地说,与旧款 Apple GPU 不同,M5 在 int8 下达到最高的算术吞吐量。

因此 Uzu 也实时将激活值(层之间传递的中间值)量化为 8 位。当每次矩阵乘法的两侧都以整数存储时,投机验证步骤就在 M5 的硬件加速 int8 矩阵路径上运行。

图片展示了Apple M5上整数执行流程。左侧为整数权重和int8矩阵输入,通过整数路径进入Apple M5 Neural Accelerators,其中整数矩阵乘法是其核心,其算术吞吐量是浮点路径的2倍。右侧是投机验证步骤,可验证多个位置,完成更多工作。底部文字说明整数格式重要,因为Uzu将其映射到更快的M5执行路径。该图与上下文紧密相关,直观呈现了Uzu在Apple M5上优化整数格式以加速执行的原理。

更低的精度带来了另一个问题。

更具体地说,分组量化在一个组包含异常值时会产生舍入误差。

组内每个值共享一个缩放因子。该缩放因子必须表示最大幅值。较小的值于是使用更少的整数级别,并产生更多的舍入误差。

Uzu 在量化之前应用块对角随机 Hadamard 变换。Hadamard 矩阵只包含 +1 和 -1 条目,外加一个整体缩放因子。

将权重块乘以它会将每个值混合到每个位置,因此一个大的异常值会变成许多中等大小的值。

“随机” 指的是在混合之前应用的随机符号翻转,“块对角” 则意味着每 32 个值的块独立进行混合。该变换完全可逆,因此除了舍入误差外,它不会改变模型的计算结果。

Mirai 选择了 32 个元素以匹配 Apple GPU 内核的 SIMD 宽度。SIMD 意味着一条指令同时运行在 32 个 GPU 线程上,因此一组线程可以变换一个块。

变换块和量化组控制着不同的操作。

  • 变换块定义了 Hadamard 矩阵混合哪些值。
  • 量化组定义了哪些值共享量化参数。

这些大小不需要匹配。

图片展示了Uzu量化组的变换前后的对比。变换前,一个组共享一个缩放,小值丢失精度,异常值范围由异常值设定;变换后,异常值幅度重新分布,共享缩放更匹配值。变换块将值混合在一起,量化组值共享缩放参数。图片与上下文紧密相关,直观呈现了量化组对数值格式、变换的优化,是Uzu差异化设计中对数值格式、变换和运行时内核联合设计的体现。

Uzu 的差异化在于对数值格式、变换和运行时内核的联合设计。量化减少了每次模型传递中的内存流量和计算量。

然而,普通的解码每次目标模型传递仍然只返回一个 token。投机解码是它用来改善这一瓶颈的下一个杠杆。

Producing several tokens from one model pass(从一次模型传递生成多个 token)

投机解码在主模型旁边增加了一个小型草稿模型。

草稿(小型)模型以低成本提出未来的 token。主模型同时检查这些位置。它接受有效的前缀,并从第一个被拒绝的 token 处继续。

假设草稿模型提出五个 token,主模型同意前四个。那么这一次验证传递就使序列推进了五个 token——四个被接受的草稿加上主模型在第五个位置自身的 token。

普通解码生成相同文本需要五次独立的主模型传递。

图片展示了普通解码和推测性解码两种生成文本的方式。普通解码中,主模型需四次传递才能生成四个被接受的token;推测性解码中,草稿模型提出四个token,主模型一起验证,一次传递即可生成四个被接受的token。图片与上下文紧密相关,直观呈现了推测性解码能从一次模型传递推进多个token,且主模型仍控制最终输出,保留目标模型输出分布的特点。

主模型仍然控制最终输出。草稿模型只提出可以并行检查的 token。正确的接受过程保留了目标模型的输出分布。

验证步骤比单 token 解码需要更多的计算。

但这条路仍然更受青睐,因为本地批量为一的推理在权重通过内存移动时,往往有闲置的算术容量。

投机解码利用这些闲置的算术能力来减少主模型传递的次数。

这种权衡取决于接受率。更长的草稿产生更大的验证批次,也带来更多早期错误使后续 token 失效的机会。

图片展示了Uzu草稿生成器(drafter)与目标模型验证的流程。上方为Draft model,草稿提出3到4个token,验证批次较小;下方为Uzu drafter,草稿预算为16到32个token,验证批次较大。图片说明草稿预算不是接受token数量,更长的草稿增加早期拒绝机会,Uzu构建更大验证批次,但有用收益仍依赖于接受度。

许多模型原生系统草拟三到四个 token。一个常见的例子是多 token 预测(MTP),即在模型本身中训练额外的预测头来猜测接下来的几个 token。

Uzu 在其 M5 路径上使用 16 到 32 个 token 的预算。本文后面使用的 Qwen3.5 9B 检查点配备了针对 M5 系列芯片的 16-token 树预算。

这些更大的批次给 M5 的神经加速器提供了足够的工作量。这些是构建在每个 GPU 核心中的矩阵单元,只有矩阵乘法有大量行时它们才划算。一次检查 16 个候选 token 给它们 16 行工作,而不是 1 行。

更长的并行草稿带来了连贯性问题。

Issue with parallel draft generation(并行草稿生成的问题)

快速的并行草稿器一次性预测多个未来位置。

如预期,每个位置都看到现有上下文,但看不到在更早草稿位置选择的 token。

考虑一个以 “She went to the store to buy.” 结尾的提示词。独立的位置可能偏好 “a”、“few”、“of” 和 “milk”。每个 token 单独看都看似合理。组合在一起却产生 “a few of milk”。

这张图直观呈现了并行草稿生成存在的Issue:现有上下文无法让各个未来位置感知彼此的选择,图中以"She went to the store to buy"作为上下文,对应Future position1到Future position4分别为"a""few""of""milk",每个位置仅能看到自身上下文,虽单个token看起来合理,却组成了"a few of milk"这类无效序列。图中清晰标注,早期的失配会拒绝后续的链,最终导致并行预测虽与提示词契合,却彼此存在分歧,这正是并行草稿生成接受率趋于下降的核心原因。

早期的失配会拒绝后续的链。这就是随着并行草稿增长接受率趋于下降的原因。更多位置为单独看似合理的 token 形成无效序列创造了更多机会。

**Trees from Marginals 论文**引入了 Weaver 来修复这一弱点。Weaver 是论文研究设置中的一个 56.7M 参数的自动回归适配器。它操作于候选候选列表,而不是在全词汇表上进行投影。

图片展示了“speculative decoding”(推测性解码)的词云图。图中以不同大小的词呈现了相关词汇,如“guesses”“tokens”“speed”“batched”等,其中“guesses”“tokens”“speed”等词汇较大,突出显示。该图与文档中介绍并行草稿生成问题及Weaver自动回归适配器的内容相关,直观呈现了与推测性解码相关的关键词,帮助理解其在文本生成中的应用。

并行的 DFlash 草稿器首先一次性预测每个未来位置的可能候选。Weaver 然后按顺序从这些候选中选择。每次选择都可以依赖于更早的选择。

之前的例子现在可以变成 “a gallon of milk”。后来的选择遵循已经选择的路径。

广告媒流(小程序)-AI生图

Weaver 还可以保留多条连贯路径。一个分支可能包含 “a gallon of milk”。另一个可能包含 “a bottle of juice”。主模型验证树并接受最长的有效路径。

树的形状可以适应提示词。可预测的代码可以支持长而窄的分支。开放式写作可能需要更短的分支和更多替代方案。

论文报告称,相比优化后的 DFlash 基线改善了 24.7%。它还报告了相比自动回归解码 4.37 倍的加速。那些实验使用了 CUDA 和 SGLang。它们是研究结果,不是普遍适用的 Uzu 测量。

Uzu 将相同的草稿思想应用于其面向 Apple 的运行时。剩余的问题是在目标模型上高效地验证树。

Verifying a draft tree without restoring model state(在不恢复模型状态的情况下验证草稿树)

一些模型层维护一个持续的内部状态。每个新 token 读取该状态并更新它。对于单序列,运行时按顺序应用这些更新。

草稿树产生了几个可能的后续序列。每个分支都会以不同方式更新状态。如果运行时在检查一个分支时更新其实时状态,它必须在检查另一个分支之前恢复先前的状态。在大树上重复该过程会增加内存操作和重复工作。

Qwen3.5 和 Qwen3.6 包含称为 Gated DeltaNet 层的有状态层。注意力层维护一个不断增长的过去每个 token 的缓存。Gated DeltaNet 层则将其所见的一切压缩成固定大小的状态,而每个被接受的 token 都会改变该状态。

在 Qwen3.5 9B 中,32 层中有 24 层是这种类型。

Uzu 将检查分支与提交其状态分开。在验证期间,它计算每个候选分支所需的值,而不改变实时状态。主模型然后选择被接受的路径。Uzu 仅对被接受的 token 应用状态更新。

这份 **Uzu 实现说明**将其称为免回滚树验证。其 Metal 内核以单次无循环遍过程计算整棵树的 DeltaNet 输出,而非逐分支逐 token 地推进。

随后,状态仅沿选定路径提交,因此永远不会出现需要回滚的错误更新。

这一点很重要,因为只有当验证成本低于逐 token 常规解码时,投机解码才有意义。反复恢复状态会消耗掉部分收益。

Weaver 与免回滚验证相结合,使 Uzu 能够提出长 token 序列,并在不反复恢复模型状态的情况下对其进行校验。

What this looks like on a base M5(在基础款 M5 上的表现)

为了对比常规解码、传统投机解码和 Uzu 方法的差异,我们在配备基础款 M5 芯片和 16 GB 统一内存的 MacBook Pro 上,用三种方式运行了同一个 Qwen3.5 9B 模型:

  • llama.cpp,无投机解码
  • llama.cpp,使用模型内置 MTP 头提前起草 3 个 token,即传统配置
  • Uzu,搭配 DFlash 和 Weaver

三者均使用 4-bit 权重和相同的提示词:「编写一个 Python 函数,用于合并两个已排序列表。」

Published benchmark comparisons(已发布的基准对比)

目前已有一些针对 Uzu 的公开基准,用于衡量输出速度、输入速度、内存占用和量化质量。

以下投机解码结果使用 **Qwen3.6 27B Mirai-M**。该检查点占用 15.6 GB。Mirai 在配备 128 GB 内存的 M5 Max 上进行了测量。

这些数据显示 Uzu 的速度为:

  • MTPLX 的 2.1 倍
  • llama.cpp 的 3.8 倍
  • MLX 的 4.4 倍。

此外,Uzu 在无投机情况下每秒可达 35.54 个 token。其投机解码结果达到每秒 114 个 token,这意味着投机路径带来了 3.2 倍的增益。

还有按提示词类型划分的输出生成速度结果。

代码和数学中包含大量可预测的延续内容。开放式对话给起草者提供的可靠 token 则较少。因此,投机速度是工作负载属性,而非固定模型参数。

Run a model locally and inspect the runtime(本地运行模型并检查运行时)

体验 Uzu 最快的方式是其命令行工具。它通过 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 在配备基础款 M5 芯片、16 GB 统一内存、运行 macOS 26.6.2 的 MacBook Pro 上进行了测试。首次运行会下载模型。

以下是实时运行情况:

输出答案后,CLI 打印了以下统计信息:

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
input energy per token: 0.11 J/tok
output energy per token: 0.16 J/tok
duration: 7.36 s

这些数字的含义如下:

  • 首个 token 时间指答案第一个 token 出现前的等待时长,其中大部分时间用于读取提示词。
  • 预填充速度指引擎读取提示词的速度,单位是每秒处理的提示词 token 数。我们的提示词仅约 20 个 token,太短,这个数字说明不了太多问题。提示词越长,该数值会越高。
  • 生成速度指答案写入速度,单位是每秒输出的 token 数。这就是前面提到的解码阶段,受内存带宽限制。
  • 每次前向传播的 token 数指 9B 模型每次前向传播平均产出的输出 token 数。常规解码恰好为 1.00。
  • 内存使用指引擎占用的统一内存,主要是模型权重加上起草者和缓存。

8.39 t/f 的数值体现了投机路径的作用。平均每次 9B 模型的前向传播产生约 8 个 token。这就是在单 token 限制约 29 的芯片上生成速度能达到 98 tok/s 的原因。

不同运行之间速度略有差异。在我们对该命令的多次运行中,生成速度在 92 到 117 tok/s 之间,每次前向传播产出 7.5 到 9.6 个 token。

投机按芯片启用。该检查点的投机器配置列出了 M5、M5 Pro 和 M5 Max,因此在 M1 至 M4 Mac 上,同一命令运行时不带起草者,并报告 1.00 t/f。

CLI 会输出延迟、吞吐量、内存、能耗和投机数据。现在我们可以从应用程序中调用相同的运行时。

Call Uzu through a local application endpoint(通过本地应用程序端点调用 Uzu)

Uzu 可以暴露一个兼容 OpenAI 的 HTTP 端点。模型仍然运行在用户 Mac 上,HTTP 请求仅经过本地回环接口。

在一个终端中启动服务器:

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

服务器加载一个本地检查点,并在 127.0.0.1 上监听,同一台 Mac 上的进程可以调用它。这对于已经通过 OpenAI 聊天协议交互的现有应用程序非常有用。

默认情况下,Uzu 保留前缀缓存。它存储先前提示词已处理的开头部分,因此重复的提示词可以跳过大部分预填充。--no-prefix-cache 可关闭此功能,使每次测试请求都执行完整工作,重复运行保持可比性。

现有应用可以通过将 OpenAI 客户端指向 http://127.0.0.1:8000/v1 来切换到 Uzu。下一节将使用一个小型客户端,以完全相同的方式对 Uzu、MLX 和 llama.cpp 进行计时。

为 Uzu、MLX 和 llama.cpp 使用同一个客户端

以下是在基础版 M5 上通过每个引擎运行的相同提示词。每个引擎独立运行,机器上没有其他任务。

llama.cpp 和 MLX 都低于每秒 29 个 token 的限制,这符合每轮仅产生一个 token 的预期。在这次记录中,Uzu 在同一芯片上的写入速度约为 MLX 的 3.7 倍。

要使用自己的提示词进行此比较,请将每个引擎作为本地服务器启动,并使用一个客户端对三者进行计时。

本地服务器接口为我们提供了一种实用的比较方法。

Uzu、MLX LM 和 llama.cpp 都可以提供 OpenAI 风格的聊天端点。我们可以将 Qwen3.5 9B 运行在三个引擎上,并向每个服务器发送相同的提示词。

使用 Mirai 的 4 位 Medium 检查点 启动 Uzu:

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

使用同一基础模型的 4 位 MLX 转换 启动 MLX LM:

brew install uv
uv tool install mlx-lm
uv tool update-shell

uv tool update-shell 将包含 mlx_lm.server 的目录添加到您的 shell 路径中。

运行后打开一个新终端,然后启动服务器:

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

使用 Mirai 已发布比较中使用的 Q4_K_S GGUF 检查点 启动 llama.cpp:

brew install llama.cpp
llama-server --version

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

-c 8192 防止 llama.cpp 预留模型的完整 262K 上下文,因为这在 16 GB Mac 上无法与权重共存。 -np 1 运行单个槽位。要像第一个视频中那样启用 llama.cpp 的 MTP 起草,请添加 --spec-type draft-mtp --spec-draft-n-max 3。MTP 需要 -np 1。

每个服务器首次启动时会下载其检查点。请一次运行一个服务器,以便在测量期间只有一个 Qwen3.5 9B 副本占用统一内存。

这三个检查点来自同一个 Qwen3.5 9B 模型,但它们不使用相同的文件格式或量化配方。Uzu 使用 Mirai-M,MLX 使用自己的 4 位转换,llama.cpp 使用 Q4_K_S。因此,这是对开发者可以运行的完整引擎路径的比较,而非仅限内核的测试。

对端口 8000、8001 和 8002 运行基准客户端,每次一个服务器。

测试工具应保持提示词文本、输出限制、采样设置和运行次数固定。使用可比的量化质量,而不仅仅是相似的位宽。在端到端客户端时间之外,记录原生引擎指标。

最后,这是一个独立的客户端。将其保存为 compare_client.py。它仅使用 Python 的标准库,并向任何兼容 OpenAI 的本地端点发送相同的确定性提示词。

它关闭思考功能,使每个引擎直接回答,并询问每个服务器的 token 使用情况。它报告首个 token 的时间、端到端时间和解码速度。

Uzu 还会报告其运行的目标模型遍数,因此对于 Uzu,脚本也会打印每遍的 token 数。

import json, os, sys, time, urllib.request

PROMPT = "Write a Python function that merges two sorted lists."

body = json.dumps({
    "model": os.environ["MODEL"],
    "messages": [{"role": "user", "content": PROMPT}],
    "temperature": 0,                                    # same answer every run
    "max_tokens": 1024,
    "stream": True,                                      # tokens arrive as they are made
    "stream_options": {"include_usage": True},           # server reports token counts
    "chat_template_kwargs": {"enable_thinking": False},  # answer directly, no thinking
}).encode()
request = urllib.request.Request(os.environ["ENDPOINT"], body, {"Content-Type": "application/json"})

start = time.perf_counter()
first = last = None
usage = {}

with urllib.request.urlopen(request, timeout=600) as response:
    for line in response:
        line = line.decode().strip()
        if not line.startswith("data:"):
            continue
        data = line[5:].strip()
        if data == "[DONE]":
            break
        event = json.loads(data)
        usage = event.get("usage") or usage
        for choice in event.get("choices") or []:
            delta = choice.get("delta") or {}
            text = delta.get("content") or ""
            if text or delta.get("reasoning_content") or delta.get("reasoning"):
                last = time.perf_counter()
                first = first or last
            print(text, end="", flush=True)

if first is None:
    sys.exit("No tokens received. Check the server log.")

tokens = usage.get("completion_tokens", 0)
print(f"\n\ntime to first token: {first - start:.2f} s")
print(f"end-to-end time:     {time.perf_counter() - start:.2f} s")
if tokens > 1 and last > first:
    print(f"decode speed:        {(tokens - 1) / (last - first):.1f} tok/s")
if usage.get("spec_verify_ct"):  # only Uzu reports how many model passes it ran
    print(f"tokens per pass:     {tokens / usage['spec_verify_ct']:.2f}")

一次运行一个引擎

一次只运行一个服务器,以便模型的一个副本驻留在统一内存中,且两个引擎不会争用内存带宽。

对于每个引擎,在一个终端中启动服务器,然后在第二个终端中运行客户端两次。第一次运行包括预热工作,因此丢弃它并保留第二次。在继续之前,使用 Ctrl-C 停止服务器。

对于 Uzu,在第一个终端中启动服务器:

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

从第二个终端运行客户端:

ENDPOINT=http://127.0.0.1:8000/v1/chat/completions \
MODEL=trymirai/Qwen3.5-9B-M \
python3 compare_client.py

运行客户端两次。丢弃第一次结果,因为它包含预热工作。保留第二次结果以及 Uzu 服务器打印的解码统计信息。然后使用 Ctrl-C 停止 Uzu。

在第一个终端中启动 MLX:

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

从第二个终端运行相同的客户端:

ENDPOINT=http://127.0.0.1:8001/v1/chat/completions \
MODEL=mlx-community/Qwen3.5-9B-4bit \
python3 compare_client.py

同样,丢弃第一次运行并保留第二次。使用 Ctrl-C 停止 MLX。

在第一个终端中启动 llama.cpp:

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

从第二个终端运行客户端:

ENDPOINT=http://127.0.0.1:8002/v1/chat/completions \
MODEL=qwen3.5-9b-q4-k-s \
python3 compare_client.py

丢弃第一次运行并保留第二次。比较应保持以下设置固定:

  • Mac 及其电源模式
  • 提示词和聊天模板
  • 输出 token 限制
  • 温度和采样设置
  • 预热次数
  • 测量请求的数量

客户端的解码速度包括 HTTP 流式传输,因此预计会略低于每个引擎在其日志中打印的速度。比较客户端的数字,而不是与 CLI 数字比较。

Mirai CLI 没有用于关闭推测的开关,因此此过程比较完整的引擎路径,而不是隔离 DFlash-Weaver。

在第一段视频中,llama.cpp 在启用和不启用 MTP 的情况下运行,是观察投机解码效果的最接近的同引擎视角。

Conclusion(结论)

加载量化检查点只是本地推理的第一步。

真正的工作在于,当每个请求都在用户的设备上以批量大小为一运行时,保持生成的实用性。

Uzu 在多个层面处理这个问题。

  • 量化减少了内存流量。
  • 其 Metal 内核针对 Apple 硬件优化。
  • DFlash-Weaver 提出了更长的 token 序列,而无需回滚的验证则检查这些序列,无需重复恢复循环状态。

在基础 M5 上,这一组合将 4 位 9B 模型从使用普通 llama.cpp 的每秒 22 个 token 提升到使用 Uzu 处理相同提示词时的每秒 92 个。

接受率随工作负载而变化。硬件、量化质量、上下文长度和热状态也会改变结果。正确的测试应使用应用程序的提示词,并同时测量质量和延迟。

如果您想深入了解,**Uzu 仓库**包含引擎、本地服务器、构建说明以及 Rust、Swift、Python 和 TypeScript 的绑定。

从本地服务器开始,运行本文中的基准客户端,并将示例提示词替换为实际产品中的请求。

git clone https://github.com/trymirai/uzu.git

**在 GitHub 上找到 Uzu →**

(别忘了给它加星标 ⭐️)

祝您愉快!