用llama.cpp实测Dory循环推理成本
教程介绍如何构建指定版本的 llama.cpp 分支并运行 NVIDIA Dory 1.0 4B 循环语言模型。内容覆盖逐轮计算成本分析、内存缓存测试与配对评测。
AI 深度解读
Dory 1.0 4B 想解决一个很实际的问题:同一份模型,能不能按任务难度,多花或少花一点推理算力?NVIDIA Nemotron Labs 的做法是让中间一组网络层重复执行,每个 token 可以跑 1 到 4 轮,不增加参数,也不用重新训练。
原文的价值在于把模型结构、启动参数、缓存预算和验证方法串了起来。不过,提供的内容在 GSM8K 数据集加载处截断,没有完整准确率结果,也没有实际解码速度表。读它可以学会怎样验证这种模型,暂时不能据此认定「多跑几轮一定更划算」。
多跑几轮,为什么不用多装一个模型
Dory 的循环发生在生成每个 token 的内部。它不是先写一个答案,再把答案交给模型检查,而是重复使用同一组网络权重,增加单个 token 经历的计算。
GGUF 元数据记录了 72 个层条目,按输入、循环、输出分成 16、40、16 个。这里的条目交替对应注意力层和 MLP,因此换成完整模块,是 8、20、8 个。注意力结构也有区别:每 3 个滑动窗口注意力层,搭配 1 个全注意力层。
作者按张量统计,模型共有约 39.8 亿参数,中间可复用的循环部分占约 18.4 亿。输入部分、输出部分和 LM head 每个 token 只执行一次,循环部分执行指定次数。
因此,4 轮相对 1 轮,单个 token 经过的参数量是 2.51 倍,估算读取的权重字节量是 2.28 倍,并非简单的 4 倍。对于受内存带宽限制的解码过程,后一个比例更有参考意义,但它仍然不是实测耗时。
参数不增加,只代表模型权重不变,不代表计算和缓存免费。
真正容易踩坑的是默认值和运行版本
按原文所述,当时 Dory 对 llama.cpp 的支持还在草稿 PR #30306 中,不能直接拿已发布二进制复现。作者使用模型卡指定的分支,并固定到提交 98a74c97ea09ac72f6ceda1559808fc0a2f447ce。这是原文对应的状态,不能据此判断现在的上游支持情况。
模型来自 nvidia/NVIDIA-Nemotron-Labs-Dory1.0-4B-GGUF,固定仓库修订为 df53755cb8741843af8e019e088819fbef17e29b。实际文件名是 NVIDIA-Nemotron-Labs-Dory1.0-4B-Q5_K_M.gguf,模型卡命令里的 dory-q5_k_m.gguf 是另一个名字,照抄前需要对应本地路径。
文件大小为 3,673,288,864 字节,约 3.67 GB;SHA-256 为 96080be49ffd67b916018b7bdef8c484762576fb52780e10ab98ac4978a660e0。作者还固定了 gguf==0.19.0,用于读取元数据。文件、解析工具和运行代码都有明确版本,后续比较才有可追溯性。
另一个坑更直接:模型卡推荐 2 轮,GGUF 内置的 dory.num_loops 却是 4。不覆盖配置,服务会按更昂贵的档位运行。
原文通过 --override-kv 传入 dory.num_loops=int:2,dory.recurrent_kv_cache_mode=str:per_loop。轮数和缓存模式在模型加载时读取,每换一种配置,都要重新启动服务。
作者没有只看启动命令,还解析日志中的 KV 缓存层数和容量,并启动一个不传覆盖参数的服务作对照。这种验证很实用:参数写进命令,不等于运行结果已经符合预期。
缓存省下来以后,答案还一样吗
KV cache 保存此前 token 的注意力键和值,让生成过程复用已有计算。Dory 多了循环维度,也就多了一个选择:各轮分别保留缓存,还是只保留一份。
per_loop 为循环部分的每轮注意力分别保存缓存;last_loop 只保留一份。原文按分支的分配规则推算,在 --ctx-size 4096、微批大小 512、键和值均为 f16 时,per_loop 从 1 轮的 306 MiB 增至 4 轮的 816 MiB,last_loop 则保持 306 MiB。
这些数字指 KV 缓存,不能当成服务总显存。模型权重和其他运行缓冲仍然占空间。少存缓存也不等于少执行循环,不能直接推出解码速度会同步提高。
作者设计了 7 组配置:1 轮使用 per_loop,2、3、4 轮分别测试两种模式。测速调用 /completion,固定提示词,设置 n_predict: 192、temperature: 0、ignore_eos: True、cache_prompt: False、seed: 1,每组执行 3 次取中位数,分别记录预填充和解码速度。
启动时还固定 --parallel 1、--batch-size 512、--ubatch-size 512、--flash-attn off 和 --no-context-shift,减少其他配置的干扰。但提供的原文没有测速输出,无法给出具体 tokens/s,也无法断言瓶颈属于计算还是带宽。
正确性检查则分成两层:用 seed: 7、温度 1.0 重复请求 /v1/chat/completions,检查消息是否一致;再通过 -ngl 0 启动 CPU 路径,与 GPU 或 Metal 在 5 个提示词上的下一 token 预测比较,同时查看共有前 10 个候选的对数概率差异。
接入聊天接口时,还要避免把推理内容当成最终答案。下面这段原始 Python 辅助函数同时处理独立字段和标签两种返回形式:
def split_reasoning(message):
"""服务能解析时通过 reasoning_content 返回思考,否则思考留在 <think> 标签内。"""
content, reasoning = message.get("content") or "", message.get("reasoning_content") or ""
m = re.search(r"<think>(.*?)</think>", content, re.S)
if m:
reasoning, content = reasoning + m.group(1), content.replace(m.group(0), "")
return reasoning.strip(), content.strip()
要看的是读取 reasoning_content 的那一行:客户端不能只读取 content,也不能假设思考内容永远采用同一种格式。这段函数处理的是完整消息,没有实现流式标签解析。
我的判断
我认可原文的验证顺序:先查模型结构和默认值,再预测资源消耗,最后用运行日志与测量核对。相比只贴一条启动命令,这套方法更适合排查本地模型「能跑,但行为不符合预期」的问题。
但需要分清证据。作者报告了文件校验和结构统计结果;缓存数字来自明确的分配规则推算;提供的内容没有展示完整测速表、后端一致性结果,以及 last_loop 对预测影响的完整实验。
固定 seed 的两次输出相同,也只支持该配置下这次重复实验可复现。5 个提示词上的下一 token 检查适合作为快速排错,不能证明所有后端、长文本和复杂任务都等价。代码甚至允许其中 1 个提示词的最高概率 token 不一致,不能把检查通过写成完全一致。
作者原计划用配对 GSM8K 评估比较准确率、答案长度和每道解对题目的耗时,数据集修订固定为 740312add88f781978c0658806c59bc2815b9866。这个方向合理,因为每秒生成多少 token,无法单独回答解决一道题需要多久。
这份材料支持「算力档位可调」,尚不足以支持「轮数越多越值得」。
与直接更换模型、调整输出长度相比,Dory 的特点是同一检查点可以改变内部计算次数。但原文没有这些替代方案的对照数据,也没有代码任务评测,不能替它推导出 AI 编程收益。
能拿来做什么
- 在本地试跑 Dory 时,先确认运行分支、GGUF 文件和校验值,再显式设置轮数。把服务日志中的缓存容量与预期核对,避免无意中使用默认 4 轮。
- 显存紧张时,可以比较
per_loop与last_loop,同时记录答案变化。306 MiB 的缓存预算只是资源线索,不能代替质量验证。 - 用 Node 接入聊天接口时,分别处理
reasoning_content和content,兼容完整响应中的<think>标签;需要流式展示时,另行设计跨消息块的解析。 - 比较循环档位时,固定题目、采样参数和输出预算,同时记录正确率、输出长度与总耗时。若用于日常 AI 编程,应补充自己的代码任务,原文的 GSM8K 评估设计不能直接代表编码能力。