怎么读这一卷
主线位置:三条主线的总览。本页用推理主线的第三步「前向,logits 对上」做例子,讲一对对照程序怎么跑、两边的数按什么标准算对上,再把三条主线的每一步对到讲它的页。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB py["llm_json.py"] --> pj["pt.json"] rs["candle crate"] --> cj["candle.json"] pj --> cmp["compare.py"] cj --> cmp cmp -->|"不超过容差"| ok["OK 退出码 0"] cmp -->|超出| bad["MISMATCH 退出码 1"]
锁定版本
| 项 | 版本 | 说明 |
|---|---|---|
| PyTorch | 2.15.0 | Python 3.13.18、numpy 2.5.4;macOS 上的 wheel。对照默认在 CPU 上做,个别页对照 MPS |
| transformers | 5.22.0 | tokenizers 0.23.3、safetensors 0.8.0;一律 HF_HUB_OFFLINE=1,只读本地权重 |
| candle | 0.11.0 | candle-core、candle-nn、candle-transformers,features metal 和 accelerate |
| burn | 0.22.1 | 只在个别地方一句话对照 |
candle 源码只认 tag 0.11.0(没有 v),链接一律是 https://github.com/huggingface/candle/blob/0.11.0/...。PyTorch 和 transformers 一侧只写在虚拟环境里跑出来的行为,个别地方引用 transformers 5.22.0 安装目录里的几行代码,不链接它们的源码。
平台:M2 Max、macOS 27、rustc 1.99.0。
accelerate 这个 feature 让 candle 在 CPU 上把矩阵乘交给 Apple 的 Accelerate 框架,不开时用 gemm crate(CPU 后端)。这一卷默认开着。PyTorch 一侧,torch.__config__.show() 的构建信息里写着 BLAS_INFO=accelerate:两边链接的是同一个系统 BLAS。下面会看到,这并不能让结果逐位相同。
每章的结构
每个内容页的顺序固定:
- 「主线位置」:这一页是哪条主线的哪一步。
- 版本行和一张图。
- 正文按对照点分节。每一节先给 PyTorch 代码,再给 candle 代码,然后是两边的真实输出。先说概念上相同的部分,再说哪里不同、为什么不同,candle 内部的机制链到 candle 源码解析。
- 说「对得上」的地方,给出最大绝对误差、输出的量级(logits 给最大 |ref|)和容差,不是 0 时说明原因。
## 小结。
一对对照程序
推理主线的主例子是 SmolLM2-135M-Instruct:Llama 结构,30 层,134 515 008 个参数,权重文件是 bf16。两边都载成 f32,输入同一个 prompt The capital of France is(5 个 token),取最后一个位置的 49152 个 logits,再贪心生成 20 个 token。
transformers 一侧。注意力用 eager,也就是 transformers 里逐个算子写出来的那一版,和 candle 的写法最接近;默认的 sdpa 和 eager 有什么差别,见对齐 logits:
import json
import sys
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_dir = sys.argv[1] # 本地的 SmolLM2-135M-Instruct 权重
torch.set_num_threads(8)
tok = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForCausalLM.from_pretrained(
model_dir, dtype=torch.float32, attn_implementation="eager"
).eval()
ids = tok("The capital of France is", return_tensors="pt").input_ids
with torch.inference_mode():
logits = model(ids).logits[0, -1]
out = model.generate(ids, max_new_tokens=20, do_sample=False)
json.dump({"logits": logits.tolist(), "greedy": out[0, ids.shape[1]:].tolist()}, sys.stdout)
candle 一侧,用 candle_transformers::models::llama。token id 直接写死(两边的分词一致,见分词与对话模板);VarBuilder 在加载时把 bf16 转成 f32:
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let dev = match args.get(1).map(String::as_str) {
Some("metal") => Device::new_metal(0)?,
_ => Device::Cpu,
};
let model_dir = args.get(2).context("model dir")?; // 例如 "SmolLM2-135M-Instruct"
let cfg: LlamaConfig = serde_json::from_slice(&std::fs::read(format!("{model_dir}/config.json"))?)?;
let cfg = cfg.into_config(false);
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[format!("{model_dir}/model.safetensors")], DType::F32, &dev)?
};
let model = Llama::load(vb, &cfg)?;
let ids: Vec<u32> = vec![504, 3575, 282, 4649, 314]; // "The capital of France is"
let mut cache = Cache::new(true, DType::F32, &cfg, &dev)?;
let input = Tensor::new(ids.as_slice(), &dev)?.unsqueeze(0)?;
let logits = model.forward(&input, 0, &mut cache)?.squeeze(0)?;
let mut greedy = vec![logits.argmax(0)?.to_scalar::<u32>()?];
for pos in ids.len()..ids.len() + 19 {
let x = Tensor::new(&[greedy[greedy.len() - 1]], &dev)?.unsqueeze(0)?;
let l = model.forward(&x, pos, &mut cache)?.squeeze(0)?;
greedy.push(l.argmax(0)?.to_scalar::<u32>()?);
}
let logits: Vec<f32> = logits.to_vec1()?;
println!("{}", serde_json::json!({ "logits": logits, "greedy": greedy }));
Ok(())
}
Llama::forward 只返回最后一个位置的 logits,所以这里拿到的就是 [49152],和 Python 的 logits[0, -1] 对应。第二个参数 index_pos 是这一段输入在序列里的起始位置,decode 时要自己往前推(KV 缓存)。
依赖:
[features]
default = ["accelerate"]
accelerate = ["candle-core/accelerate", "candle-nn/accelerate", "candle-transformers/accelerate"]
[dependencies]
candle-core = { version = "0.11", features = ["metal"] }
candle-nn = { version = "0.11", features = ["metal"] }
candle-transformers = { version = "0.11", features = ["metal"] }
anyhow = "1"
serde_json = "1"
跑法:
HF_HUB_OFFLINE=1 python -I llm_json.py SmolLM2-135M-Instruct > pt.json
cargo run --release --offline -- cpu SmolLM2-135M-Instruct > candle_cpu.json
python -I compare.py pt.json candle_cpu.json 1e-4
compare.py 递归走两份 JSON,对每个字段把所有数摊平,打印最大绝对误差;任何一个字段超出容差就退出码 1。token id 也走同一个比较:差一个 token,差值至少是 1,必然超出容差。
a, b = (json.load(open(p)) for p in sys.argv[1:3])
tol = float(sys.argv[3]) if len(sys.argv) > 3 else 1e-6
worst = 0.0
def walk(x, y, path):
global worst
if isinstance(x, dict):
for k in x:
walk(x[k], y[k], f"{path}.{k}" if path else k)
return
fx, fy = list(flat(x)), list(flat(y))
assert len(fx) == len(fy), (path, len(fx), len(fy))
d = max(abs(p - q) for p, q in zip(fx, fy))
worst = max(worst, d)
print(f"{path:24} n={len(fx):3} max|diff|={d:.2e}")
walk(a, b, "")
print(f"worst={worst:.2e} tol={tol:.0e} {'OK' if worst <= tol else 'MISMATCH'}")
sys.exit(0 if worst <= tol else 1)
本页实测,transformers 对 candle CPU(accelerate):
logits n=49152 max|diff|=2.98e-05
greedy n= 20 max|diff|=0.00e+00
worst=2.98e-05 tol=1e-04 OK
同一个二进制换成 Metal:
logits n=49152 max|diff|=3.53e-05
greedy n= 20 max|diff|=0.00e+00
worst=3.53e-05 tol=1e-04 OK
两次退出码都是 0,20 个贪心 token 全部相同: Paris. Paris is the largest city in France and the capital of the French department of the Espace。
一个从来不报错的检查等于没有检查,所以反过来验一次:把 transformers 输出里第一个 logit 加 0.001,再和 candle CPU 比:
logits n=49152 max|diff|=1.00e-03
greedy n= 20 max|diff|=0.00e+00
worst=1.00e-03 tol=1e-04 MISMATCH
退出码 1。
「对得上」是什么意思
看绝对误差,同时给出量级
这 49152 个 logits 里最大的 |ref| 是 20.595306。2.98e-5 放在这个量级上,约是 20 附近 f32 ulp(1.91e-6)的 16 倍,相对量级约 1.4e-6。误差的中位数是 4.29e-6,有 1033 个 logit 两边逐位相同。
最大误差却不在最大的 logit 上。它出现在第 21778 个 logit:transformers 是 -0.5872931480407715,candle 是 -0.5872633457183838。这个数附近的 ulp 只有 5.96e-8,2.98e-5 折成 ulp 是 500 个。这不说明这个 logit「算错了」:每个 logit 都是最后一层的隐藏状态和 lm_head 一行权重的点积,隐藏状态里的小差别经过 30 层累积,再乘到每一行上,绝对误差大体按整个输出的量级产生,落到一个接近 0 的 logit 上,ulp 数和相对误差就显得很大。所以这一卷判断 logits 用绝对误差,并且总是写出最大 |ref|,让读者知道这个误差相对于什么量级。
为什么不是 0
两边做的是同一组数学运算,结果却不逐位相同,原因是浮点加法不满足结合律:同一组数换一个顺序相加,舍入落在不同的地方。matmul、RmsNorm 里的平方和、softmax 的分母都是求和,每个实现按自己的顺序加。只要有一处顺序不同,最后几位就不同,再经过 30 层传下去。一个只差求和顺序的最小实验(100 万个 f32 四种求和方式得到四个结果)在 burn 卷的怎么读这一卷里,这里不重做。
下面两个数说明误差来自「实现不同」,而不是「运行有随机性」:
同一个实现,换线程数,逐位相同。 transformers 一侧把
torch.set_num_threads(8)改成 1 再跑,和 8 线程的输出比:logits n=49152 max|diff|=0.00e+00 greedy n= 20 max|diff|=0.00e+00 worst=0.00e+00 tol=1e-04 OK链接同一个 BLAS 也不够。 两边的 matmul 都可以走 Accelerate,结果仍然差 2.98e-5:除了矩阵乘,RmsNorm、RoPE、softmax、注意力的拼法都是各自的代码。candle 关掉
accelerate,matmul 换成gemmcrate,exp、silu这类一元运算和 f32 / f64 的向量加法也从 Accelerate 的向量函数换回 Rust 循环(candle-core/src/op.rs和cpu/kernels.rs里按 feature 选),和 transformers 的差变成 4.58e-5:logits n=49152 max|diff|=4.58e-05 greedy n= 20 max|diff|=0.00e+00 worst=4.58e-05 tol=1e-04 OK
accelerate 的 CPU 版本和不开的版本,logits 的最大差是多少?比它们各自和 transformers 的差(2.98e-5、4.58e-5)小很多吗?两个二进制来自同一份代码,只差一个 Cargo feature。先猜,再看文末。容差
- f32 下 SmolLM2 的 logits,容差 1e-4。 最大 |ref| 是 20.6,1e-4 约是它的 4.9e-6 倍。本页三种 candle 构建(CPU accelerate、CPU gemm、Metal)和 transformers 的最大差在 2.98e-5 到 4.58e-5 之间,1e-4 留了 2 倍多的余量;而上面加的 0.001 远远超出。别的模型、别的 dtype(bf16、f16、量化)误差大得多,那些页另定容差并写明依据。
- token id、形状、dtype、整数结果要求完全相同。 这些不涉及舍入,差了就是行为不同,比如整数除法的取整方向(整数运算)。贪心生成要求逐个 token 相同;用到取样的地方两边的随机数发生器不同,只比较语义(取样)。
- 训练主线的 MLP 数小、规模小,容差 1e-6,和 burn 卷一样;具体数字见训练一步。
三条主线落在哪一页
推理主线(本卷的主轴):读 config 和权重 → 建模块 → 前向,logits 对上 → 带 KV 缓存生成 → 量化与部署。
| 步骤 | 两边的差别 | 讲它的页 |
|---|---|---|
| 读 config 和权重 | from_pretrained 对 from_mmaped_safetensors;.bin / .pt;键名映射 | safetensors、PyTorch 的 .bin / .pt、键名对不上 |
| 建模块 | nn.Module 对结构体加 Module trait;VarBuilder 按名字取权重 | 定义一个模块、常用层 |
| 前向,logits 对上 | 一段段对 LlamaForCausalLM;逐层对数;半精度 | 对齐 logits、逐层对数、半精度 |
| 带 KV 缓存生成 | past_key_values 对 Cache 和 index_pos;generate 对手写循环;取样;对话模板 | KV 缓存、生成循环、取样、分词与对话模板 |
| 量化与部署 | GGUF;自己量化;速度;一个二进制 | GGUF、自己量化、速度、部署 |
张量主线:建张量 → 计算 → 读回主机。
| 步骤 | 两边的差别 | 讲它的页 |
|---|---|---|
| 建张量 | torch.tensor 对 Tensor::new;浮点字面量是 F64;没有类型提升 | 创建与 dtype |
| 计算 | + 不广播;matmul 不升维;reshape | 形状与广播 |
| 计算 | 归约去掉维度;argmax 是 U32;索引越界返回 Err | 归约与索引 |
| 计算 | 截断除法、无符号回绕 | 整数运算 |
| 计算 | 每个方法返回 Result;运算接引用、张量不可变 | Result 到处都是、引用与不可变 |
| 读回主机 | Device::Cpu / new_metal(0);Metal 读回时才等完成 | 设备 |
训练主线:forward → loss → backward → 优化器一步。
| 步骤 | 两边的差别 | 讲它的页 |
|---|---|---|
| 打开求导、backward | requires_grad=True 对 Var;梯度在 GradStore 里 | Var 与梯度 |
| 不求导 | 没有 torch.no_grad;融合算子没有反向 | 没有 no_grad |
| 优化器一步 | SGD 没有动量;AdamW;手写训练循环 | 训练一步 |
阅读路线
- 要搬一个 transformers 模型:04 模块 → 05 权重 → 06 搬一个 LLM → 07 生成,遇到张量上的意外再回 01、02 查。
- 只关心交付:什么时候选 candle → 08 量化 → 09 性能与部署。
- 已经在写 candle、被 PyTorch 习惯绊住:直接查 10 常见坑。
- 想从头写一遍:11 动手,不用
candle-transformers手写 SmolLM2 的前向。
动脑筋答案
最大差 3.62e-5,和它们各自与 transformers 的差是同一个量级:
greedy n= 20 max|diff|=0.00e+00
logits n=49152 max|diff|=3.62e-05
worst=3.62e-05 tol=1e-04 OK
- 两个 candle 二进制的模型代码完全相同,差别只在 CPU 内核:一个的 matmul 调 Accelerate 的
sgemm、一元运算调 Accelerate 的向量函数,另一个用gemmcrate 和 Rust 循环。底层换一套求和顺序和近似,就足以让 logits 差到 3.62e-5。 - 所以 2.98e-5 不是「candle 和 PyTorch 哪一边算错了」,而是两个正确的 f32 实现之间正常的距离。
greedy一行差 0:20 个 token 三方都一样。logit 上 1e-5 量级的差,不足以改变哪个 token 最大。
小结
- 版本锁定 PyTorch 2.15.0、transformers 5.22.0、candle 0.11.0(
metal、accelerate);candle 源码链接只认 tag0.11.0。 - 每个对照是一个
.py加一个 cargo crate,输出同样结构的 JSON,compare.py逐字段报最大绝对误差,超出容差退出码 1;给 transformers 的一个 logit 加 0.001,它报 MISMATCH。 - SmolLM2-135M f32,最后一个位置的 49152 个 logits(最大 |ref| 20.6):candle CPU 差 2.98e-5,Metal 差 3.53e-5,关掉
accelerate差 4.58e-5;20 个贪心 token 全部相同。 - 不是 0 是因为求和顺序不同:torch 换线程数逐位相同,candle 只换 CPU 内核(开不开
accelerate)就差 3.62e-5。最大误差落在一个 -0.587 的 logit 上,所以用绝对误差并写出量级。 - 容差:f32 logits 1e-4;token、形状、dtype、整数结果要求完全相同;MLP 训练 1e-6;别的 dtype 另定并写明依据。