从头写 Llama
主线位置:推理主线从头走一遍:读 config 和权重 → 建模块 → 前向,logits 对上 → 带 KV 缓存生成。06、07 用的是
candle_transformers::models::llama,这一页把它换成自己写的约 310 行。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal),candle 侧只依赖
candle-core和candle-nn
flowchart TB cfg["config.json"] --> own["Config from_json"] st["safetensors"] --> vb["VarBuilder"] own --> load["Model load"] vb --> load ids["token ids"] --> emb["index_select"] load --> emb emb --> blk["30 个 Block"] blk --> nrm["RmsNorm"] nrm --> head["共享的 lm_head"] head --> lg["每个位置的 logits"] cache["Cache"] --> blk blk -->|"存回 k v"| cache
实验代码:Python 侧 ref.py(transformers 的参照)、allpos.py、resave.py、bench.py;candle 侧 crate handson-scratch(本页的程序,全文在页末)、handson-ropei(反向检查)、handson-fused(把零件逐个换成 llama.rs 的写法,用环境变量切换)、handson-ref(同一个 main,模型换成 candle_transformers::models::llama)。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。模型是本地的 SmolLM2-135M-Instruct 权重,两边都载成 f32;PyTorch 固定 torch.set_num_threads(8),注意力用 eager。
写什么、用什么
transformers 的 LlamaForCausalLM 拆开是下面这些零件。每一行在 transformers 和 llama.rs 里怎么对应,对齐 logits 已经一段段对过;这里每个零件都自己写,只借 candle-nn 的 Linear([out, in] 布局,见常用层)、VarBuilder 和 softmax_last_dim:
| transformers 5.22.0 | 本页的写法 | 用到的 candle API |
|---|---|---|
LlamaConfig | Config::from_json,自己从 serde_json::Value 里取 | 无 |
embed_tokens | 一个 [49152, 576] 的张量,index_select | VarBuilder::get |
LlamaRMSNorm | x * (mean(x²) + eps)^(-1/2) * w,逐个算子写 | sqr、mean_keepdim、recip |
LlamaRotaryEmbedding + rotate_half | 主机上算角度表,前后两半配对 | narrow、cat |
repeat_kv | unsqueeze + broadcast_as + reshape,和 transformers 同样的 expand | broadcast_as |
create_causal_mask(eager) | 加法 mask,不能看的位置是 -inf | broadcast_add |
eager_attention_forward | q·kᵀ·d^(-1/2)、加 mask、softmax、乘 v | matmul、candle_nn::ops::softmax_last_dim |
LlamaMLP | down(silu(gate(x)) * up(x)) | linear_no_bias、silu |
lm_head(tie_word_embeddings) | Linear::new(embed_tokens.clone(), None) | Linear::new |
DynamicCache | Vec<Option<(k, v)>>,位置从缓存长度推出 | Tensor::cat |
Python 一侧就是 transformers 的标准用法:
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 一侧的 main 做同样的事:读 config 和权重,prefill 一次,再带缓存逐个生成,输出同样结构的 JSON(全文在页末):
let mut cache = Cache::new(cfg.layers);
let logits = model.forward(&Tensor::new(ids.as_slice(), &dev)?.unsqueeze(0)?, &mut cache)?;
let last = logits.i((0, ids.len() - 1))?.to_vec1::<f32>()?;
// ...
while out.len() < steps && Some(out[out.len() - 1]) != cfg.eos {
let x = Tensor::new(&[out[out.len() - 1]], &dev)?.unsqueeze(0)?;
let l = model.forward(&x, &mut cache)?.i((0, 0))?.to_vec1::<f32>()?;
out.push(argmax(&l));
}
结果
HF_HUB_OFFLINE=1 python -I ref.py SmolLM2-135M-Instruct > pt.json
handson-scratch cpu SmolLM2-135M-Instruct > cpu.json # release 构建的二进制
python -I compare.py pt.json cpu.json 1e-4
rope_theta=100000 head_dim=64 heads=9/3
prompt ids [504, 3575, 282, 4649, 314]
text " Paris. Paris is the largest city in France and the capital of the French department of the Espace"
load 99 ms, prefill 5 tokens 27.3 ms, decode 19 tokens 277.9 ms (68.4 tokens/s)
logits n=49152 max|diff|=3.67e-05
greedy n= 20 max|diff|=0.00e+00
worst=3.67e-05 tol=1e-04 OK
Metal 上同一个程序(第一个参数换成 metal):
logits n=49152 max|diff|=4.10e-05
greedy n= 20 max|diff|=0.00e+00
worst=4.10e-05 tol=1e-04 OK
最后一个位置的 49152 个 logits,最大 |ref| 20.6,容差 1e-4(依据见怎么读这一卷):
| 对比 | CPU | Metal | 20 个贪心 token |
|---|---|---|---|
本页的程序对 transformers eager | 3.67e-5 | 4.10e-5 | 20/20 |
llama.rs 对 transformers eager | 2.98e-5 | 3.53e-5 | 20/20 |
本页的程序对 llama.rs | 3.81e-5 | 4.29e-5 | 20/20 |
forward 返回每个位置的 logits,不只是最后一个,所以 prompt 的 5 个位置都能和 transformers 的 model(ids).logits[0] 对(handson-fused 加 ALLPOS=1 把它们全部输出,计算和本页的程序相同)。5 个位置的最大 |ref| 是 23.2、20.43、20.52、25.51、20.6:
cpu ['4.77e-05', '4.20e-05', '4.48e-05', '4.39e-05', '3.67e-05'] argmax same [True, True, True, True, True]
metal ['1.72e-05', '3.29e-05', '5.44e-05', '4.15e-05', '4.10e-05'] argmax same [True, True, True, True, True]
都落在 1e-4 以内,但和 llama.rs 一样不是 0,本页的程序和 llama.rs 之间也不是 0。下面先看每个零件自己写时要做的决定,再看这几个数从哪来。
config:自己读,读不到就报错
llama.rs 用 serde 把 config.json 反序列化成固定的 13 个字段,缺的用默认值,多的忽略(对齐 logits)。自己写时可以反过来:只读需要的键,遇到实现不了的取值就返回 Err:
// transformers 4 写在顶层;transformers 5 存出来的 config 放在 rope_parameters 里
let rope = &v["rope_parameters"];
let rope_theta = v["rope_theta"]
.as_f64()
.or(rope["rope_theta"].as_f64())
.context("config.json: no rope_theta, neither top level nor in rope_parameters")?;
let rope_type = rope["rope_type"].as_str().or(v["rope_scaling"]["rope_type"].as_str());
ensure!(matches!(rope_type, None | Some("default")), "rope_type {rope_type:?} not implemented");
let act = v["hidden_act"].as_str().unwrap_or("silu");
ensure!(act == "silu", "hidden_act {act} not implemented");
for key in ["attention_bias", "mlp_bias"] {
ensure!(!v[key].as_bool().unwrap_or(false), "{key}=true not implemented");
}
head_dim 先读文件里的,没有才用 hidden_size / num_attention_heads;num_key_value_heads 缺省等于头数,tie_word_embeddings 缺省 false,和 transformers 的缺省值相同。
用 transformers 5.22.0 AutoConfig.from_pretrained(..).save_pretrained(..) 重新存一份 config,顶层的 rope_theta 没了,只剩 rope_parameters:
top-level rope_theta: None
rope_parameters: {'rope_theta': 100000, 'rope_type': 'default'}
本页的程序读这份 config,从 rope_parameters 里取到 100000,logits 和原来逐位相同(对 transformers 仍是 3.67e-5,20/20)。llama.rs 读同一份文件静默用 10000,logits 差 0.61(对齐 logits)。再把 rope_parameters 里的 rope_theta 也删掉:
Error: config.json: no rope_theta, neither top level nor in rope_parameters
退出码 1。transformers 读同一份文件不报错,AutoConfig 补上缺省值:
{'rope_type': 'default', 'rope_theta': 10000.0}
两个库在这里的行为一样,都是「缺了就当 10000」。自己写的版本没有理由照抄:SmolLM2 用的是 100000,静默的缺省值只会让错误晚一点、远一点暴露。
RmsNorm 和 RoPE:逐个算子写
transformers 5.22.0 的 LlamaRMSNorm.forward 先升到 f32,再乘 rsqrt:
hidden_states = hidden_states.to(torch.float32)
variance = hidden_states.pow(2).mean(-1, keepdim=True)
hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
return self.weight * hidden_states.to(input_dtype)
fn forward(&self, x: &Tensor) -> Result<Tensor> {
let inv_rms = (x.sqr()?.mean_keepdim(D::Minus1)? + self.eps)?.sqrt()?.recip()?;
Ok(x.broadcast_mul(&inv_rms)?.broadcast_mul(&self.weight)?)
}
mean_keepdim 保留最后一维,后面的 broadcast_mul 才对得上(mean 会去掉维度,见归约与索引)。这里只跑 F32,没有写升降 dtype 那两步。candle-nn 有融合版 candle_nn::ops::rms_norm,llama.rs 用的 candle_nn::RmsNorm 在输入连续时调的就是它(impl Module for RmsNorm),一次内核算完;它和上面这串算子的结果不逐位相同,后面会量到。
RoPE 的配对方式和 transformers 的 rotate_half 一样:第 i 维和第 i + 32 维一对(HF 格式的权重配哪一种、配错差多少,见对齐 logits)。把 q * cos + rotate_half(q) * sin 按前后两半展开,就是两行:
fn rope(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
let half = x.dim(D::Minus1)? / 2;
let x1 = x.narrow(D::Minus1, 0, half)?;
let x2 = x.narrow(D::Minus1, half, half)?;
let y1 = (x1.broadcast_mul(cos)? - x2.broadcast_mul(sin)?)?;
let y2 = (x1.broadcast_mul(sin)? + x2.broadcast_mul(cos)?)?;
Ok(Tensor::cat(&[y1, y2], D::Minus1)?)
}
cos、sin 是 [t, 32],每次 forward 在主机上按当前的位置算,角度和 transformers 一样是 位置 × inv_freq,inv_freq[i] = 1 / rope_theta^(2i/64),都在 f32 里算。llama.rs 是在 Cache::new 里一次算好 8192 行,再 narrow 出需要的几行。
用一个随机的 [1, 9, 5, 64] 和随机角度,拿这个函数和 candle-nn 的两个融合算子比:
Cpu hand rope vs rope: 0.000e0 hand interleaved vs rope_i: 0.000e0 hand rope vs rope_i: 5.461e0
Metal { gpu_id: 4294968354 } hand rope vs rope: 2.384e-7 hand interleaved vs rope_i: 2.384e-7 hand rope vs rope_i: 6.039e0
CPU 上和融合的 rope 逐位相同,Metal 上差一个 f32 舍入;和 rope_i(交错配对)差 5 到 6,是另一种东西。第二列的 hand interleaved 是下面反向检查用的那个版本。
注意力:GQA、mask 和 KV 缓存
GQA 的复制照 transformers 的 repeat_kv 写,expand 对应 broadcast_as:
hidden_states = hidden_states[:, :, None, :, :].expand(batch, num_key_value_heads, n_rep, slen, head_dim)
return hidden_states.reshape(batch, num_key_value_heads * n_rep, slen, head_dim)
let (b, kv, t, d) = x.dims4()?;
Ok(x.unsqueeze(2)?.broadcast_as((b, kv, n, t, d))?.reshape((b, kv * n, t, d))?)
broadcast_as 之后不连续,reshape 会做一次复制,结果是连续的,正好给后面的 matmul 用。复制顺序是查询头 0、1、2 用 KV 头 0,和 transformers 相同(对齐 logits)。
因果 mask 照 transformers eager 的形式写成加法 mask,只是不能看的位置用 -inf,不用 f32 的最小值:
fn causal_mask(t: usize, past: usize, dev: &Device) -> Result<Option<Tensor>> {
if t == 1 {
return Ok(None); // 只有一个查询,它能看全部缓存
}
let total = past + t;
let m: Vec<f32> = (0..t)
.flat_map(|i| (0..total).map(move |j| if j > past + i { f32::NEG_INFINITY } else { 0.0 }))
.collect();
Ok(Some(Tensor::from_vec(m, (t, total), dev)?))
}
past 是已经缓存的长度,mask 是 [t, past + t],分段 prefill 时也对。llama.rs 的 mask 是 u8,再用 where_cond 换成 -inf。这两种写法在这个模型上给出的 logits 逐位相同(下一节把它换成 where_cond 量过)。
问: transformers 用 torch.finfo(torch.float32).min,这里用 -inf,softmax 之后不都是 0 吗,为什么 transformers 要用一个有限的数?
答: 区别在一整行都被遮住的时候。因果 mask 每一行至少能看自己,不会出现;带 padding 的批里,左侧补齐的位置就可能整行被遮。给一行 [0.3, 1.2, -0.5] 全部加上 mask 再 softmax:
Cpu all masked, -inf: [NaN, NaN, NaN] f32::MIN: [0.33333334, 0.33333334, 0.33333334]
Metal { gpu_id: 4294968354 } all masked, -inf: [NaN, NaN, NaN] f32::MIN: [0.33333334, 0.33333334, 0.33333334]
all masked, -inf [[nan, nan, nan]]
all masked, finfo.min [[0.3333333432674408, 0.3333333432674408, 0.3333333432674408]]
前两行是 candle 的 softmax_last_dim,后两行是 torch.softmax,行为相同:-inf 得 NaN,这个位置的注意力输出就是 NaN;最小值得到均匀分布,输出没有意义,但仍是有限的数。本页只跑 batch 1、不补齐,用 -inf 没问题;要加批和 padding,就换成 f32::MIN。
KV 缓存是每层一对 (k, v),存 GQA 复制之前的、已经转过 RoPE 的键和值,沿序列维(第 2 维)接上:
let (k, v) = match kv.take() {
Some((pk, pv)) => (Tensor::cat(&[&pk, &k], 2)?, Tensor::cat(&[&pv, &v], 2)?),
None => (k.contiguous()?, v.contiguous()?),
};
*kv = Some((k.clone(), v.clone()));
和 llama.rs 最大的不同在位置从哪来。llama.rs 的 forward(x, index_pos, cache) 要调用方传 index_pos,传错不报错(KV 缓存);这里和 transformers 的 DynamicCache 一样,从缓存里第 0 层键的长度推出来:
fn forward(&self, ids: &Tensor, cache: &mut Cache) -> Result<Tensor> {
let (b, t) = ids.dims2()?;
let past = cache.len()?;
let rot = self.rope_table(past, t, ids.device())?;
let mask = causal_mask(t, past, ids.device())?;
RoPE 表和 mask 都由 past 决定,所以不会出现「缓存长 24、位置却从 0 开始」的组合。换一个 prompt 就 Cache::new 一个新的,Cache 只是一个 Vec,丢掉就清空了。
SwiGLU 和共享权重的 lm_head 没有要选的地方:Linear::new(embed_tokens.clone(), None) 让 lm_head 和词嵌入指向同一块存储(Tensor 的 clone 只加引用计数,见引用与不可变),config.json 里 tie_word_embeddings 为 false 时才去读 lm_head.weight。
反向检查:把 RoPE 配对写反
上面的 3.67e-5 只有在「写错就会超出容差」时才有说服力。把 rope 换成交错配对(第 2i 和 2i + 1 维一对,GGUF 里的 Llama 用这种),其余一个字不改:
fn rope(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
// 交错配对:第 2i 维和第 2i + 1 维(candle 的 rope_i,GGUF 里的 Llama 用这种)
let (b, h, t, d) = x.dims4()?;
let x = x.reshape((b, h, t, d / 2, 2))?;
let x1 = x.narrow(4, 0, 1)?.squeeze(4)?;
let x2 = x.narrow(4, 1, 1)?.squeeze(4)?;
let y1 = (x1.broadcast_mul(cos)? - x2.broadcast_mul(sin)?)?;
let y2 = (x1.broadcast_mul(sin)? + x2.broadcast_mul(cos)?)?;
Ok(Tensor::stack(&[y1, y2], 4)?.reshape((b, h, t, d))?)
}
这个函数和 rope_i 逐位相同(上一节的 hand interleaved 一列),所以它是一个正确的交错 RoPE,只是不是这份权重要的那种。跑同一个对照:
logits n=49152 max|diff|=7.89e+00
greedy n= 20 max|diff|=1.13e+04
worst=1.13e+04 tol=1e-04 MISMATCH
text " Paris.\n\n\n\nThe capital of France is Paris.\n\n\n\nThe capital of"
CPU 和 Metal 都是 7.89,compare.py 退出码 1,20 个 token 只有前 2 个相同。程序本身照常跑完、不报错,生成的仍是像样的英文,只是很快开始重复:这种错只有和参照逐个数比才看得出来。
差别从哪来
三个实现两两差 3e-5 到 4e-5,llama.rs 和本页的程序算的是同一个数学式,差别只能来自写法。handson-fused 把零件一个个换成 llama.rs 的写法:
LAST_ONLY:lm_head只对最后一个位置算(llama.rs的Llama::forward),本页的程序对 5 个位置一起算;FUSED_RMS:RmsNorm 换成candle_nn::ops::rms_norm;FUSED_ROPE:RoPE 换成candle_nn::rotary_emb::rope。
和 handson-ref(llama.rs)的最后一个位置 logits 比:
| 换掉的零件 | CPU 对 llama.rs | Metal 对 llama.rs |
|---|---|---|
| 无(本页的程序) | 3.81e-5 | 4.29e-5 |
FUSED_RMS | 2.38e-5 | 3.72e-5 |
LAST_ONLY | 3.62e-5 | 4.01e-5 |
LAST_ONLY + FUSED_RMS | 0 | 3.34e-5 |
LAST_ONLY + FUSED_RMS + FUSED_ROPE | 0 | 0 |
- CPU 上只要两处:
lm_head算几行、RmsNorm 用不用融合算子。两处一换,和llama.rs逐位相同。lm_head作为 5 行矩阵的最后一行去乘和单独 1 行去乘结果不同,是 BLAS 分块的事,transformers 自己也一样(对齐 logits的动脑筋)。 - Metal 上还要再加 RoPE:手写的 RoPE 和融合内核差一个舍入(上面的 2.4e-7),CPU 上两者逐位相同。
- 剩下的写法差别都不改变一个比特:在最后一行的基础上再把 mask 换成
where_cond(WHERE)、把 KV 头的复制换成cat(KVCAT)、把 RoPE 表换成加载时一次算好 8192 行再narrow(PRETABLE),单独换或三个一起换,CPU 和 Metal 上对llama.rs都还是 0。乘0.125和除以 8 在浮点里本来就相同。
对 transformers 来说这些都是同一量级的舍入:五种组合对 transformers 的差在 2.98e-5 到 3.96e-5(CPU)、3.34e-5 到 4.29e-5(Metal)之间,没有哪一种写法「更准」。
和 candle_transformers::models::llama 比
少了什么、多了什么
llama.rs 有、本页的程序没有的:
- Llama 3 的
rope_scaling:Cache::new里按llama3类型改写频率。本页遇到非default的rope_type直接返回Err。 - 半精度:
llama.rs在注意力里把 q、k、v 转成 F32 算 softmax,最后把 logits 转回 F32(半精度)。本页只在 F32 下写和测过。 - flash-attn:
use_flash_attn走 CUDA 的candle-flash-attn,这台机器用不上。 - 其他调用方要的接口:
embed、forward_input_embed(注释写着给 LLaVA 用)、手写的config_7b_v1/config_7b_v2、tracing的 span、use_kv_cache开关。 - 预先算好的表:8192 行的 cos / sin 表和按形状缓存的 mask,本页每次
forward现算。
本页的程序有、llama.rs 没有的:
- 读
head_dim和rope_parameters,检查hidden_act、偏置和rope_type;rope_theta读不到时报错,不用 10000。 - 位置从缓存长度推出来,没有
index_pos参数;换 prompt 新建一个Cache即可。 - 返回每个位置的 logits(
[b, t, vocab]),可以算困惑度、看每个位置的预测;llama.rs固定只返回最后一个位置。 - 缺层时返回
Err。把num_hidden_layers改成 31:
Error: cannot find tensor model.layers.30.input_layernorm.weight
退出码 1;llama.rs 在 Llama::load 里对每一层 unwrap,同样的 config 会 panic(对齐 logits)。
- 没有 8192 的位置上限:RoPE 角度按位置现算,不从表里
narrow。这一条是从代码推的,没有跑到 8192 个位置验证。
llama.rs 还有一段本页没写的缓存截断(CausalSelfAttention::forward 里 if cache.use_kv_cache 那一段):k 接上缓存后长度超过 max_position_embeddings 就截掉前面的部分。判断用的是 k.dims()[1],而这时 k 是 [b, kv_heads, seq, head_dim],第 1 维是 KV 头数(SmolLM2 是 3),除非 KV 头比 max_position_embeddings 还多,条件不会成立;即使成立,narrow 的维度写的是 D::Minus1(head_dim),也截错了地方。这段代码最早写于缓存还是 [heads, seq, d]、第 1 维就是序列长度的时候;PR #144「Llama batch」加批维时,cat 改到了第 2 维,dims()[1] 没改,narrow 改成了 D::Minus1。origin/main 上的 llama.rs 和 0.11.0 相同。实际不会出事,因为位置一到 8192,RoPE 表的 narrow 先报错(KV 缓存)。
行数
| 总行数 | 去掉空行和注释 | 内容 | |
|---|---|---|---|
本页 main.rs | 311 | 269 | 模型 256 行 + main 55 行(参数、分词、生成循环、计时、JSON) |
| 本页的模型部分 | 256 | 220 | 到 fn main 之前 |
llama.rs | 534 | 479 | 只有模型;另用 utils.rs 的 repeat_kv、build_causal_mask 和 with_tracing 的包装 |
两份格式不同,不能直接按行比:本页是 2 空格缩进、长行不折,llama.rs 是 rustfmt 默认的 4 空格、按 100 列折行。llama.rs 多出来的部分里,两个手写的 7B 配置占 38 行,Llama 3 的频率改写约 30 行,其余是 tracing、flash-attn 和上面那些接口。
速度
同一个 main(prompt、生成循环、计时都相同),模型换成三种:本页的程序、handson-fused 开 FUSED_RMS + FUSED_ROPE、llama.rs。decode 是默认 prompt 后生成 129 个 token,算后 128 步每步的平均时间;prefill 是一段 183 个 token 的英文只跑一次 forward(含读回最后一个位置的 logits)。每种组合跑 10 遍、三种实现轮流跑,取最小值。开始时 uptime 的负载是 9.43,结束时 7.15(别的实验同时在跑)。Metal 着色器库的缓存是热的:之前已经跑过几十次(冷启动见源码卷的 Metal 第一次运行要好几秒)。candle CPU 用默认的 rayon 线程数。
| CPU decode(ms/token) | CPU prefill 183(ms) | Metal decode(ms/token) | Metal prefill 183(ms) | |
|---|---|---|---|---|
| 本页的程序 | 14.98 | 78.5 | 8.63 | 67.8 |
| + 融合 RmsNorm、RoPE | 15.15 | 88.5 | 5.91 | 53.6 |
llama.rs | 15.04 | 108.3 | 4.08 | 47.3 |
- CPU decode 三者相同,零件怎么写不影响。
- CPU prefill 本页的程序反而最快。拆开量(每种 6 遍取最小,负载 4.9 左右):本页的程序 74.8 ms,只把 mask 换成
where_cond就到 101.2 ms;把llama.rs的四种写法(LAST_ONLY、两个融合算子、cat复制 KV 头)和where_cond全换上是 109.8 ms,llama.rs同一轮也是 109.8 ms。差的 26 ms 几乎全在 mask 上:where_cond的三个输入都是广播到[1, 9, 183, 183]的视图,加法 mask 只是一次broadcast_add;where_cond在 CPU 上为什么慢这么多,没有再往下拆。两个融合算子在 CPU 上也慢一点(RmsNorm +7.9 ms、RoPE +4.8 ms,各 8 遍取最小),原因同样没有拆。 - Metal decode 本页的程序慢一倍。每步要发的内核多:手写的 RmsNorm 和 RoPE 各拆成七八个算子,融合版各一个;每层两次 RmsNorm、两次 RoPE,30 层下来每个 token 多出几百个内核。换成融合算子从 8.63 降到 5.91;再把 KV 头的复制从
broadcast_as+reshape换成cat(另一轮,各 5 遍取最小)从 5.90 降到 4.51,离llama.rs的 4.08 只剩 0.4 ms。
最后这一条,llama.rs 以前也是 broadcast_as 的写法:
repeat_kv 改用 cat 是为了快PR #2043(「Use cat for faster MQA computation.」,首次进入的 release 是 0.5.1;0.5.0 的版本号提交在它之前)之前,llama.rs 的 repeat_kv 是 x.unsqueeze(2)?.expand((b_sz, n_kv_head, n_rep, seq_len, head_dim))?.reshape(..),和 transformers、和本页的写法一样。这个 PR 把它挪进 candle-transformers/src/utils.rs,改成 Tensor::cat(&vec![&xs; n_rep], 2)?.reshape(..),同一次提交里 gemma、mistral、mixtral、qwen2 等十几个模型改用这个共享函数。源码注释写的理由是:「Using cat is faster than a broadcast as it avoids going through a potentially strided copy.」两种写法结果逐位相同(上一节换成 cat 后对 llama.rs 仍是 0),速度差在 Metal decode 上量到的是每个 token 1.4 ms,CPU 上量不出来。
完整程序
Cargo.toml(features 和全卷的模板相同;tokenizers 只用来编码 prompt 和解码输出):
[package]
name = "handson-scratch"
version = "0.1.0"
edition = "2024"
[dependencies]
candle-core = { version = "0.11", features = ["metal", "accelerate"] }
candle-nn = { version = "0.11", features = ["metal", "accelerate"] }
anyhow = "1"
serde_json = "1"
tokenizers = { version = "*", default-features = false, features = ["onig"] }
src/main.rs:
//! SmolLM2-135M(Llama 结构)从头写,只用 candle-core 和 candle-nn。
//! 用法:scratch <cpu|metal> <model_dir> [steps] [prompt]
//! stdout:{"logits": 最后一个位置的 logits, "greedy": 贪心生成的 id};stderr:文本和计时。
use anyhow::{Context, Result, bail, ensure};
use candle_core::{D, DType, Device, IndexOp, Tensor};
use candle_nn::{Linear, VarBuilder, linear_no_bias};
use serde_json::Value;
use std::time::Instant;
struct Config {
hidden: usize,
intermediate: usize,
layers: usize,
heads: usize,
kv_heads: usize,
head_dim: usize,
vocab: usize,
eps: f64,
rope_theta: f32,
tie: bool,
eos: Option<u32>,
}
fn int(v: &Value, key: &str) -> Result<usize> {
let n = v[key].as_u64().with_context(|| format!("config.json: no integer `{key}`"))?;
Ok(n as usize)
}
impl Config {
fn from_json(v: &Value) -> Result<Self> {
let (hidden, heads) = (int(v, "hidden_size")?, int(v, "num_attention_heads")?);
// transformers 4 写在顶层;transformers 5 存出来的 config 放在 rope_parameters 里
let rope = &v["rope_parameters"];
let rope_theta = v["rope_theta"]
.as_f64()
.or(rope["rope_theta"].as_f64())
.context("config.json: no rope_theta, neither top level nor in rope_parameters")?;
let rope_type = rope["rope_type"].as_str().or(v["rope_scaling"]["rope_type"].as_str());
ensure!(matches!(rope_type, None | Some("default")), "rope_type {rope_type:?} not implemented");
let act = v["hidden_act"].as_str().unwrap_or("silu");
ensure!(act == "silu", "hidden_act {act} not implemented");
for key in ["attention_bias", "mlp_bias"] {
ensure!(!v[key].as_bool().unwrap_or(false), "{key}=true not implemented");
}
Ok(Self {
hidden,
intermediate: int(v, "intermediate_size")?,
layers: int(v, "num_hidden_layers")?,
heads,
kv_heads: int(v, "num_key_value_heads").unwrap_or(heads),
head_dim: int(v, "head_dim").unwrap_or(hidden / heads),
vocab: int(v, "vocab_size")?,
eps: v["rms_norm_eps"].as_f64().context("config.json: no rms_norm_eps")?,
rope_theta: rope_theta as f32,
tie: v["tie_word_embeddings"].as_bool().unwrap_or(false),
eos: v["eos_token_id"].as_u64().map(|e| e as u32),
})
}
}
struct RmsNorm {
weight: Tensor,
eps: f64,
}
impl RmsNorm {
fn load(vb: VarBuilder, size: usize, eps: f64) -> Result<Self> {
Ok(Self { weight: vb.get(size, "weight")?, eps })
}
fn forward(&self, x: &Tensor) -> Result<Tensor> {
let inv_rms = (x.sqr()?.mean_keepdim(D::Minus1)? + self.eps)?.sqrt()?.recip()?;
Ok(x.broadcast_mul(&inv_rms)?.broadcast_mul(&self.weight)?)
}
}
/// 非交错 RoPE,和 transformers 的 rotate_half 同一种配对:第 i 维和第 i + d/2 维。
/// x: [b, heads, t, d];cos、sin: [t, d/2]
fn rope(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
let half = x.dim(D::Minus1)? / 2;
let x1 = x.narrow(D::Minus1, 0, half)?;
let x2 = x.narrow(D::Minus1, half, half)?;
let y1 = (x1.broadcast_mul(cos)? - x2.broadcast_mul(sin)?)?;
let y2 = (x1.broadcast_mul(sin)? + x2.broadcast_mul(cos)?)?;
Ok(Tensor::cat(&[y1, y2], D::Minus1)?)
}
/// GQA:[b, kv, t, d] -> [b, kv * n, t, d],KV 头 j 给查询头 j*n .. j*n+n-1 用。
fn repeat_kv(x: &Tensor, n: usize) -> Result<Tensor> {
if n == 1 {
return Ok(x.contiguous()?);
}
let (b, kv, t, d) = x.dims4()?;
Ok(x.unsqueeze(2)?.broadcast_as((b, kv, n, t, d))?.reshape((b, kv * n, t, d))?)
}
/// 加法因果 mask:查询 i(绝对位置 past + i)只能看键 0 ..= past + i。
fn causal_mask(t: usize, past: usize, dev: &Device) -> Result<Option<Tensor>> {
if t == 1 {
return Ok(None); // 只有一个查询,它能看全部缓存
}
let total = past + t;
let m: Vec<f32> = (0..t)
.flat_map(|i| (0..total).map(move |j| if j > past + i { f32::NEG_INFINITY } else { 0.0 }))
.collect();
Ok(Some(Tensor::from_vec(m, (t, total), dev)?))
}
struct Attention {
q_proj: Linear,
k_proj: Linear,
v_proj: Linear,
o_proj: Linear,
heads: usize,
kv_heads: usize,
head_dim: usize,
}
impl Attention {
fn load(vb: VarBuilder, c: &Config) -> Result<Self> {
let (q, kv) = (c.heads * c.head_dim, c.kv_heads * c.head_dim);
Ok(Self {
q_proj: linear_no_bias(c.hidden, q, vb.pp("q_proj"))?,
k_proj: linear_no_bias(c.hidden, kv, vb.pp("k_proj"))?,
v_proj: linear_no_bias(c.hidden, kv, vb.pp("v_proj"))?,
o_proj: linear_no_bias(q, c.hidden, vb.pp("o_proj"))?,
heads: c.heads,
kv_heads: c.kv_heads,
head_dim: c.head_dim,
})
}
fn forward(&self, x: &Tensor, rot: &(Tensor, Tensor), mask: Option<&Tensor>, kv: &mut Option<(Tensor, Tensor)>) -> Result<Tensor> {
let (b, t, _) = x.dims3()?;
let split = |proj: &Linear, n: usize| -> Result<Tensor> {
Ok(x.apply(proj)?.reshape((b, t, n, self.head_dim))?.transpose(1, 2)?)
};
let q = rope(&split(&self.q_proj, self.heads)?, &rot.0, &rot.1)?;
let k = rope(&split(&self.k_proj, self.kv_heads)?, &rot.0, &rot.1)?;
let v = split(&self.v_proj, self.kv_heads)?;
// KV 缓存:存 GQA 复制之前的 k、v,沿序列维(第 2 维)接上
let (k, v) = match kv.take() {
Some((pk, pv)) => (Tensor::cat(&[&pk, &k], 2)?, Tensor::cat(&[&pv, &v], 2)?),
None => (k.contiguous()?, v.contiguous()?),
};
*kv = Some((k.clone(), v.clone()));
let n = self.heads / self.kv_heads;
let (k, v) = (repeat_kv(&k, n)?, repeat_kv(&v, n)?);
let att = (q.matmul(&k.t()?)? * (self.head_dim as f64).powf(-0.5))?;
let att = match mask {
Some(m) => att.broadcast_add(m)?,
None => att,
};
let att = candle_nn::ops::softmax_last_dim(&att)?;
let y = att.matmul(&v)?.transpose(1, 2)?.reshape((b, t, self.heads * self.head_dim))?;
Ok(y.apply(&self.o_proj)?)
}
}
struct Block {
input_layernorm: RmsNorm,
self_attn: Attention,
post_attention_layernorm: RmsNorm,
gate_proj: Linear,
up_proj: Linear,
down_proj: Linear,
}
impl Block {
fn load(vb: VarBuilder, c: &Config) -> Result<Self> {
let mlp = vb.pp("mlp");
Ok(Self {
input_layernorm: RmsNorm::load(vb.pp("input_layernorm"), c.hidden, c.eps)?,
self_attn: Attention::load(vb.pp("self_attn"), c)?,
post_attention_layernorm: RmsNorm::load(vb.pp("post_attention_layernorm"), c.hidden, c.eps)?,
gate_proj: linear_no_bias(c.hidden, c.intermediate, mlp.pp("gate_proj"))?,
up_proj: linear_no_bias(c.hidden, c.intermediate, mlp.pp("up_proj"))?,
down_proj: linear_no_bias(c.intermediate, c.hidden, mlp.pp("down_proj"))?,
})
}
fn forward(&self, x: &Tensor, rot: &(Tensor, Tensor), mask: Option<&Tensor>, kv: &mut Option<(Tensor, Tensor)>) -> Result<Tensor> {
let h = self.input_layernorm.forward(x)?;
let x = (x + self.self_attn.forward(&h, rot, mask, kv)?)?;
let h = self.post_attention_layernorm.forward(&x)?;
// SwiGLU:down(silu(gate(h)) * up(h))
let m = (h.apply(&self.gate_proj)?.silu()? * h.apply(&self.up_proj)?)?.apply(&self.down_proj)?;
Ok((x + m)?)
}
}
/// 每层一对 (k, v),形状 [b, kv_heads, 已缓存的长度, head_dim]。
struct Cache(Vec<Option<(Tensor, Tensor)>>);
impl Cache {
fn new(layers: usize) -> Self {
Self(vec![None; layers])
}
fn len(&self) -> Result<usize> {
Ok(match &self.0[0] {
Some((k, _)) => k.dim(2)?,
None => 0,
})
}
}
struct Model {
embed_tokens: Tensor,
layers: Vec<Block>,
norm: RmsNorm,
lm_head: Linear,
inv_freq: Vec<f32>,
}
impl Model {
fn load(vb: VarBuilder, c: &Config) -> Result<Self> {
let embed_tokens = vb.get((c.vocab, c.hidden), "model.embed_tokens.weight")?;
let lm_head = if c.tie {
Linear::new(embed_tokens.clone(), None) // 和词嵌入共用同一个张量
} else {
linear_no_bias(c.hidden, c.vocab, vb.pp("lm_head"))?
};
let layers = (0..c.layers)
.map(|i| Block::load(vb.pp(format!("model.layers.{i}")), c))
.collect::<Result<Vec<_>>>()?;
let inv_freq = (0..c.head_dim / 2)
.map(|i| 1.0 / c.rope_theta.powf((2 * i) as f32 / c.head_dim as f32))
.collect();
Ok(Self { embed_tokens, layers, norm: RmsNorm::load(vb.pp("model.norm"), c.hidden, c.eps)?, lm_head, inv_freq })
}
/// 位置 start .. start + t 的 cos、sin,形状 [t, head_dim / 2]
fn rope_table(&self, start: usize, t: usize, dev: &Device) -> Result<(Tensor, Tensor)> {
let half = self.inv_freq.len();
let angles: Vec<f32> = (start..start + t)
.flat_map(|p| self.inv_freq.iter().map(move |f| p as f32 * f))
.collect();
let angles = Tensor::from_vec(angles, (t, half), dev)?;
Ok((angles.cos()?, angles.sin()?))
}
/// ids: [b, t] -> 每个位置的 logits [b, t, vocab]。位置从缓存长度推出来,不用调用方传。
fn forward(&self, ids: &Tensor, cache: &mut Cache) -> Result<Tensor> {
let (b, t) = ids.dims2()?;
let past = cache.len()?;
let rot = self.rope_table(past, t, ids.device())?;
let mask = causal_mask(t, past, ids.device())?;
let mut x = self.embed_tokens.index_select(&ids.flatten_all()?, 0)?.reshape((b, t, ()))?;
for (layer, kv) in self.layers.iter().zip(cache.0.iter_mut()) {
x = layer.forward(&x, &rot, mask.as_ref(), kv)?;
}
Ok(self.norm.forward(&x)?.apply(&self.lm_head)?)
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: scratch <cpu|metal> <model_dir> [steps] [prompt]";
let dev = match args.get(1).map(String::as_str) {
Some("cpu") => Device::Cpu,
Some("metal") => Device::new_metal(0)?,
_ => bail!(usage),
};
let model_dir = args.get(2).context(usage)?;
let steps: usize = args.get(3).map_or(Ok(20), |s| s.parse())?;
let prompt = args.get(4).map_or("The capital of France is", String::as_str);
let t_load = Instant::now();
let json: Value = serde_json::from_slice(&std::fs::read(format!("{model_dir}/config.json"))?)?;
let cfg = Config::from_json(&json)?;
eprintln!("rope_theta={} head_dim={} heads={}/{}", cfg.rope_theta, cfg.head_dim, cfg.heads, cfg.kv_heads);
let file = format!("{model_dir}/model.safetensors");
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[file], DType::F32, &dev)? };
let model = Model::load(vb, &cfg)?;
let tok = tokenizers::Tokenizer::from_file(format!("{model_dir}/tokenizer.json")).map_err(anyhow::Error::msg)?;
let ids = tok.encode(prompt, true).map_err(anyhow::Error::msg)?.get_ids().to_vec();
let load = t_load.elapsed();
// prefill:整段 prompt 一次送进去
let mut cache = Cache::new(cfg.layers);
let t_prefill = Instant::now();
let logits = model.forward(&Tensor::new(ids.as_slice(), &dev)?.unsqueeze(0)?, &mut cache)?;
let last = logits.i((0, ids.len() - 1))?.to_vec1::<f32>()?;
let prefill = t_prefill.elapsed();
// decode:每步只送一个 token,前面的 k、v 从缓存里取
let argmax = |v: &[f32]| (0..v.len()).fold(0, |best, i| if v[i] > v[best] { i } else { best }) as u32;
let mut out = vec![argmax(&last)];
let t_decode = Instant::now();
while out.len() < steps && Some(out[out.len() - 1]) != cfg.eos {
let x = Tensor::new(&[out[out.len() - 1]], &dev)?.unsqueeze(0)?;
let l = model.forward(&x, &mut cache)?.i((0, 0))?.to_vec1::<f32>()?;
out.push(argmax(&l));
}
let decode = t_decode.elapsed();
eprintln!("prompt ids {ids:?}");
eprintln!("text {:?}", tok.decode(&out, false).map_err(anyhow::Error::msg)?);
eprintln!(
"load {:.0} ms, prefill {} tokens {:.1} ms, decode {} tokens {:.1} ms ({:.1} tokens/s)",
load.as_secs_f64() * 1e3,
ids.len(),
prefill.as_secs_f64() * 1e3,
out.len() - 1,
decode.as_secs_f64() * 1e3,
(out.len() - 1) as f64 / decode.as_secs_f64()
);
println!("{}", serde_json::json!({ "logits": last, "greedy": out }));
Ok(())
}
运行:cargo run --release --offline -- cpu SmolLM2-135M-Instruct > cpu.json,第一个参数换成 metal 用 GPU,第三、四个参数是生成的 token 数和 prompt。stdout 是和 Python 侧同样结构的 JSON,用 compare.py 比;stderr 是生成的文本和计时。
小结
- 只用
candle-core和candle-nn(Linear、VarBuilder、softmax_last_dim),约 310 行写完 SmolLM2:logits 和 transformers 差 3.67e-5(CPU)/ 4.10e-5(Metal),最大 |ref| 20.6,容差 1e-4;prompt 的 5 个位置都在 5.44e-5 以内;20 个贪心 token 全同。 - 反向检查:只把 RoPE 换成交错配对,logits 差 7.89,20 个 token 只剩前 2 个相同,程序不报错、输出仍像样的英文。
- 自己读 config 可以比库更严:
rope_parameters里的rope_theta能读到,两处都没有就报错;llama.rs顶层没有就用 10000,transformers 两处都没有时也补 10000。 - 位置从缓存长度推出,不需要
index_pos;forward返回每个位置的 logits;缺层是Err不是 panic。 - 和
llama.rs的 logits 差别只来自lm_head算几行、RmsNorm 和 RoPE 用不用融合算子,三处换成一样就逐位相同;mask 和 KV 头复制的写法不改变结果。 - 速度:CPU decode 一样(约 15 ms/token),CPU prefill 本页更快(78.5 对 108.3 ms,差在
where_condmask);Metal decode 本页慢一倍(8.63 对 4.08 ms/token),融合算子和cat复制 KV 头补回其中 4.1 ms。