Rust in Everything
基于 candle 0.11.0

从头写 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
LlamaConfigConfig::from_json,自己从 serde_json::Value 里取无
embed_tokens一个 [49152, 576] 的张量,index_selectVarBuilder::get
LlamaRMSNormx * (mean(x²) + eps)^(-1/2) * w,逐个算子写sqr、mean_keepdim、recip
LlamaRotaryEmbedding + rotate_half主机上算角度表,前后两半配对narrow、cat
repeat_kvunsqueeze + broadcast_as + reshape,和 transformers 同样的 expandbroadcast_as
create_causal_mask(eager)加法 mask,不能看的位置是 -infbroadcast_add
eager_attention_forwardq·kᵀ·d^(-1/2)、加 mask、softmax、乘 vmatmul、candle_nn::ops::softmax_last_dim
LlamaMLPdown(silu(gate(x)) * up(x))linear_no_bias、silu
lm_head(tie_word_embeddings)Linear::new(embed_tokens.clone(), None)Linear::new
DynamicCacheVec<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(依据见怎么读这一卷):

对比CPUMetal20 个贪心 token
本页的程序对 transformers eager3.67e-54.10e-520/20
llama.rs 对 transformers eager2.98e-53.53e-520/20
本页的程序对 llama.rs3.81e-54.29e-520/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 量过)。

📝NOTE
没有蠢问题

问: 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.rsMetal 对 llama.rs
无(本页的程序)3.81e-54.29e-5
FUSED_RMS2.38e-53.72e-5
LAST_ONLY3.62e-54.01e-5
LAST_ONLY + FUSED_RMS03.34e-5
LAST_ONLY + FUSED_RMS + FUSED_ROPE00
  • 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.rs311269模型 256 行 + main 55 行(参数、分词、生成循环、计时、JSON)
本页的模型部分256220到 fn main 之前
llama.rs534479只有模型;另用 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.9878.58.6367.8
+ 融合 RmsNorm、RoPE15.1588.55.9153.6
llama.rs15.04108.34.0847.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 的写法:

📝NOTE
冷知识: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_cond mask);Metal decode 本页慢一倍(8.63 对 4.08 ms/token),融合算子和 cat 复制 KV 头补回其中 4.1 ms。

💬 关联讨论

发起讨论
加载中...