生成一个 token
主线位置:推理主线从头到尾。
gguf_file::Content::read→ModelWeights::from_gguf→forward(input, pos):embedding → 每层 RmsNorm、注意力加 KV 缓存、MLP 的QMatMul→ logits → 取一个 token → 下一轮forward。每一步的源码和细讲的页见怎么读这一卷的推理主线表,本页给每一段计时。
sequenceDiagram participant U as 生成循环 participant C as gguf Content participant W as ModelWeights participant L as 30 个层 participant D as 设备 U->>C: Content read U->>W: from_gguf W->>C: 按名字取 QTensor U->>W: forward 13 个 token W->>L: RmsNorm 注意力 MLP L->>D: 每个 op 一个内核 U->>D: argmax 后 to_scalar D-->>U: 第一个新 token U->>W: forward 1 个 token W->>L: 拼 KV 缓存 U->>D: to_scalar D-->>U: 下一个 token
实验程序
模型是本地的 SmolLM2-135M-Instruct-Q8_0.gguf(144811360 字节)。实验 crate intro-gen 依赖 candle-core / candle-nn / candle-transformers 0.11.0(开 metal、accelerate)和 tokenizers 0.22.2,release 构建。一轮 run 从打开文件开始,到生成 20 个 token 为止,每一段单独计时;同一个进程里连跑几轮,把「这个进程第一次」和之后分开:
fn run(device: &Device, label: &str, steps: usize, tok: &Tokenizer) -> anyhow::Result<()> {
let t = Instant::now();
let mut f = std::fs::File::open(format!("{DIR}/SmolLM2-135M-Instruct-Q8_0.gguf"))?;
let content = gguf_file::Content::read(&mut f)?;
let t_read = t.elapsed();
let t = Instant::now();
let mut model = ModelWeights::from_gguf(content, &mut f, device)?;
device.synchronize()?;
let t_build = t.elapsed();
let mut ids = tok.encode(PROMPT, false).map_err(anyhow::Error::msg)?.get_ids().to_vec();
let mut pos = 0;
// ...
for step in 0..=steps {
let t = Instant::now();
let input = Tensor::new(&ids[pos..], device)?.unsqueeze(0)?;
let logits = model.forward(&input, pos)?.squeeze(0)?;
pos = ids.len();
// to_scalar copies the result back to the CPU, so it waits for the GPU.
let next = logits.argmax(0)?.to_scalar::<u32>()?;
steps_t.push(t.elapsed());
ids.push(next);
// ...
}
// ...
}
prompt 是 SmolLM2 的对话模板包着「What is Rust?」,编码后 13 个 token。第 0 步把 13 个 token 一起送进去(prefill),pos = 0;之后每步只送新生成的那一个(decode),pos 是已经处理过的 token 数。取样用贪心的 argmax,没有经过 LogitsProcessor(温度、top-k、top-p 见取样)。每一步末尾的 to_scalar 要把结果读回 CPU,在 Metal 上就是一次提交加等待,所以每一步的计时都包含了 GPU 真正算完的时间。
逐段走一遍
读 GGUF:130 ms,只读头部
Content::read 读魔数、版本、元数据键值和张量目录,记下数据区从哪里开始,不读任何权重。辅助程序 meta 打印出来的是:
Content::read 130.0 ms; file 144811360 bytes; tensor data starts at 1785952; 37 metadata keys; 272 tensors
Content::read via BufReader 31.4 ms (272 tensors)
general.architecture = String("llama")
...
llama.attention.head_count = U32(9)
llama.attention.head_count_kv = U32(3)
llama.block_count = U32(30)
llama.embedding_length = U32(576)
llama.feed_forward_length = U32(1536)
...
tokenizer.ggml.merges = array of 48900
...
tokenizer.ggml.token_type = array of 49152
tokenizer.ggml.tokens = array of 49152
头部只有 1.7 MB,却要 130 ms:元数据里装着整份词表,49152 个 token 字符串、48900 条 merge 规则、49152 个类型标记,每个值都是 read_u32 / read_u64 / read_exact 这样的小读取。直接传 File 时每次小读取都是一次系统调用;同样的解析套一层 std::io::BufReader,降到 31 ms。GGUF 的格式和各个字段见 GGUF 文件。
建模型:按名字取张量
ModelWeights::from_gguf 先从元数据取超参数(30 层、9 个查询头、3 个 KV 头、隐层 576),算好 rotary 的 cos / sin 表,再按 token_embd.weight、blk.{i}.attn_q.weight 这样的名字逐个调 Content::tensor:seek 到数据区里的偏移,读出原始字节,在目标设备上建一个 QTensor。投影矩阵包成 QMatMul,权重保持 Q8_0;embedding 先反量化成普通 f32 张量。272 个张量是 30 层 × 9 个加 token_embd.weight、output_norm.weight,文件里没有 output.weight,于是输出层直接用 embedding 的那个 QTensor(Err(_) => tok_embeddings_q)。各层怎么搭、QMatMul 的三种形态见量化 llama 的一次前向和 QTensor 与 QMatMul。
prefill 和 decode:同一个 forward,不同的路
ModelWeights::forward 对 prefill 和 decode 是同一个函数,靠 seq_len 分岔:输入多于一个 token 时取一个因果 mask,只有一个 token 时不要 mask。注意力在 forward_attn 里再分一次:
let y = if q.device().is_metal() && seq_len == 1 {
// SDPA will do MQA for us
candle_nn::ops::sdpa(
&q,
&k,
&v,
None,
false,
1. / (self.head_dim as f32).sqrt(),
1.,
)?
} else {
// Support for MQA, useful for 70B models and mistral.
let k = crate::utils::repeat_kv(k, self.n_head / self.n_kv_head)?;
let v = crate::utils::repeat_kv(v, self.n_head / self.n_kv_head)?;
let att = (q.matmul(&k.t()?)? / (self.head_dim as f64).sqrt())?;
// ... masked_fill,softmax_last_dim
att.matmul(&v.contiguous()?)?
};
Metal 上的 decode 走 candle_nn::ops::sdpa,一个融合内核做完 q·kᵀ、缩放、softmax、乘 v,3 个 KV 头对 9 个查询头的分组(MQA / GQA)也在内核里处理;其余情况(CPU 上的一切、Metal 上的 prefill)把 KV 头复制成 9 份,再 matmul、mask、softmax、matmul。KV 缓存是每层一对张量,每步 Tensor::cat 拼上新的一列,见 KV 缓存与注意力。投影和 MLP 里的 QMatMul 在 Metal 上也分两种内核:输入只有一行时用矩阵乘向量的 kernel_mul_mv_q8_0_f32,多行时用矩阵乘矩阵的 kernel_mul_mm_q8_0_f32(QMetalStorage::fwd 里 src_shape.dim(D::Minus2)? == 1 那一行)。
所以 prefill 和第一个 decode 步用到的内核集合不一样,这一点在下一节决定了第一次运行的时间分布。
forward_attn 里 q.device().is_metal() && seq_len == 1 这个分支来自 #2584「Add some fast Metal MLX SDPA kernels」(2024-11-05,首次发布于 0.8.0)。这个 PR 一边在 candle-metal-kernels 里加了从 MLX 移植的 scaled_dot_product_attention.metal(完整版和给 decode 用的向量版),一边在 candle-nn/src/ops.rs 加了 sdpa,提交说明里有一条「Use it in quantized llama」。之前 forward_attn 只有现在的 else 那一支:不分设备、不分 prefill 还是 decode,一律 repeat_kv 再两次 matmul 加一次 softmax。改了之后只有 Metal 上的单 token 换路,CPU 和 prefill 不受影响。代价是多了一个 2401 行的 .metal 文件:它要到第一个 decode 步才第一次用到,冷缓存时就是下面那 2.1 s。
计时
CPU
release,intro-gen cpu 3,同一进程连跑 3 轮:
[cpu round 1] read gguf 130.8 ms, build model 42.2 ms, prefill (13 tokens) 35.5 ms, 20 decode steps 170.8 ms (mean 8.5 ms), total 206.4 ms
[cpu round 1] decode steps (ms): 10.2 7.3 7.6 8.4 7.2 9.7 7.4 8.6 7.2 7.0 8.3 8.5 8.1 8.7 8.5 9.0 9.6 8.9 10.8 10.0
[cpu round 1] text: "Rust is a modern, high-performance, and object-oriented programming language developed by Brian Kern"
[cpu round 2] read gguf 129.8 ms, build model 31.0 ms, prefill (13 tokens) 35.5 ms, 20 decode steps 161.8 ms (mean 8.1 ms), total 197.3 ms
[cpu round 3] read gguf 129.5 ms, build model 30.5 ms, prefill (13 tokens) 41.7 ms, 20 decode steps 176.8 ms (mean 8.8 ms), total 218.6 ms
CPU 上第一轮和之后没有区别:没有要编译的东西。生成 20 个 token 约 0.2 s,每步约 8 ms。生成的文本 CPU 和 Metal 一字不差(「Brian Kern」是这个 135M 模型自己编的)。
Metal:热缓存
macOS 的 Metal 库缓存命中时(release,intro-gen metal 2,第二个进程):
[metal] Device::new_metal 12.3 ms
[metal round 1] read gguf 137.9 ms, build model 116.6 ms, prefill (13 tokens) 43.1 ms, 20 decode steps 70.1 ms (mean 3.5 ms), total 113.3 ms
[metal round 1] decode steps (ms): 6.4 3.8 3.8 3.8 3.4 3.6 3.3 3.2 3.3 3.7 3.2 3.4 3.9 3.3 3.5 2.9 2.9 2.9 2.8 3.0
[metal round 2] read gguf 136.0 ms, build model 115.7 ms, prefill (13 tokens) 19.7 ms, 20 decode steps 51.5 ms (mean 2.6 ms), total 71.3 ms
[metal round 2] decode steps (ms): 2.5 2.6 2.4 2.7 3.2 2.8 2.4 2.3 2.5 2.4 2.8 2.4 3.5 2.4 2.4 2.4 2.6 2.3 2.3 2.6
热的时候 Metal 比 CPU 快:decode 每步 2.5–3.5 ms 对 8 ms,prefill 20–43 ms 对 35–42 ms。建模型慢一些(约 116 ms 对 31–42 ms),多出来的是把每个张量的字节拷进 Metal 缓冲区。
Metal:冷缓存
同一个程序,用 patch 过的 candle-metal-kernels 拷贝(和 tag 一字不差,只在 load_library / load_pipeline_with_constants 周围加了计时)设 INTRO_TRACE=1 打印每次编译库(LIB)和建管线(PIPE)的耗时,再设 INTRO_NONCE 往每段 .metal 源码末尾加一行带时间戳的注释,让 macOS 的缓存一定对不上(release):
[metal] Device::new_metal 8.9 ms
--- metal round 1: build model
LIB Cast 77.5 ms
PIPE Cast cast_u32_f32 0.9 ms
LIB Gemm 3828.6 ms
PIPE Gemm gemm_nn_f32_f32_64_32_32_2_2 0.1 ms
LIB Unary 114.2 ms
PIPE Unary cos_f32 0.1 ms
PIPE Unary sin_f32 0.0 ms
--- metal round 1: prefill
LIB Indexing 70.5 ms
PIPE Indexing is_u32_f32 0.1 ms
LIB Reduce 4333.7 ms
PIPE Reduce rmsnorm_f32 0.1 ms
LIB Quantized 789.1 ms
PIPE Quantized kernel_mul_mm_q8_0_f32 0.1 ms
PIPE Unary copy_f32_strided 0.0 ms
PIPE Reduce rope_i_f32 0.0 ms
PIPE Unary copy2d_f32 0.0 ms
PIPE Gemm gemm_nt_f32_f32_32_32_16_2_2 0.0 ms
LIB Affine 26.1 ms
PIPE Affine affine_f32 0.1 ms
LIB Ternary 44.0 ms
PIPE Ternary where_u8_f32 0.1 ms
PIPE Reduce softmax_f32 0.0 ms
PIPE Gemm gemm_nn_f32_f32_32_32_16_2_2 0.0 ms
LIB Binary 566.6 ms
PIPE Binary badd_f32 0.1 ms
PIPE Unary silu_f32 0.0 ms
PIPE Binary bmul_f32 0.0 ms
PIPE Quantized kernel_mul_mv_q8_0_f32 0.1 ms
PIPE Reduce fast_argmax_f32 0.0 ms
--- metal round 1: decode step 1
LIB Sdpa 2130.6 ms
PIPE Sdpa sdpa_vector_float_64 0.1 ms
--- metal round 1: decode step 2
[metal round 1] read gguf 129.0 ms, build model 4111.2 ms, prefill (13 tokens) 5869.4 ms, 20 decode steps 2197.2 ms (mean 109.9 ms), total 8066.6 ms
[metal round 1] decode steps (ms): 2144.1 5.9 3.7 3.1 3.2 2.5 2.5 2.4 2.4 2.4 2.5 2.5 2.6 2.5 2.5 2.5 2.5 2.5 2.5 2.3
[metal round 2] read gguf 129.2 ms, build model 78.0 ms, prefill (13 tokens) 20.4 ms, 20 decode steps 51.2 ms (mean 2.6 ms), total 71.7 ms
[metal round 2] decode steps (ms): 2.5 3.0 2.5 2.4 2.5 2.5 2.4 2.7 2.5 2.4 2.4 2.9 2.5 2.5 2.5 2.5 2.5 2.5 2.5 2.5
一个 token 的生成只用到 16 个 .metal 库中的 10 个、21 条管线。冷的时候时间几乎全在 LIB 那几行:
| 阶段 | 冷 | 热 | 这一段第一次编译的库 |
|---|---|---|---|
| 建模型 | 4111 ms | 78–117 ms | Cast 78、Gemm 3829(precomput_freqs_cis 里的一次 matmul)、Unary 114 |
| prefill(13 个 token) | 5869 ms | 20–43 ms | Indexing 71、Reduce 4334(rmsnorm、softmax、rope、argmax 都在里面)、Quantized 789、Affine 26、Ternary 44、Binary 567 |
| 第 1 个 decode 步 | 2144 ms | 2.5–6 ms | Sdpa 2131(只有 decode 走 sdpa) |
| 第 2–20 个 decode 步 | 每步 2.3–5.9 ms | 每步 2.3–3.8 ms | 无 |
PIPE 都在 1 ms 以内:贵的是把整个 .metal 文件从 MSL 编译成库,按库里的函数建管线很便宜。三处大头 Gemm、Reduce、Sdpa 合计 10.3 s,所有 LIB 加起来约 12 s,和三段时间的和对得上。第二轮(同一进程)一个 LIB 都没有,和热缓存的进程一样快。
Metal 第一次为什么比 CPU 慢
没有强制、自然发生的冷运行也是这个样子。这台机器上 intro-gen 第一次构建出来就跑 Metal 的那一次(release,没有 patch,也没有 INTRO_NONCE):
[metal round 1] read gguf 128.9 ms, build model 178.2 ms, prefill (13 tokens) 6574.2 ms, 20 decode steps 2363.0 ms (mean 118.2 ms), total 8937.3 ms
[metal round 1] decode steps (ms): 2267.6 6.6 6.1 6.2 6.2 5.2 5.0 4.7 4.7 3.9 4.1 5.4 4.6 3.9 3.9 3.6 4.8 5.9 4.5 6.4
[metal round 2] read gguf 130.5 ms, build model 184.3 ms, prefill (13 tokens) 20.9 ms, 20 decode steps 79.4 ms (mean 4.0 ms), total 100.3 ms
生成 20 个 token 用了 8.9 s,而 CPU 是 0.2 s。拆开看,6.6 s 在 prefill、2.3 s 在第一个 decode 步,剩下 19 步每步 4–6 ms,已经比 CPU 快(这一次建模型只有 178 ms,说明 Gemm 等几个库恰好在缓存里)。所以:
- 慢在编译,而且只在第一次。 同一进程的第二轮、热缓存的新进程,都没有这几秒。慢的位置正好是第一次用到 Reduce、Quantized、Binary(prefill)和 Sdpa(第一个 decode 步)的地方,和上面的
LIB表一一对应。 - 不是每个 token 一次同步。 每一步都要
to_scalar读回,这一次提交加等待在 CPU 和 Metal 上都躲不掉(CPU 上只是不用等),稳定后 Metal 每步 2.5 ms 里已经含着它。下面的动脑筋用另一个旋钮验证这一点。 - 冷热由 macOS 决定。 candle 只在进程里缓存编译好的库;macOS 在
$(getconf DARWIN_USER_CACHE_DIR)com.apple.metal/另有一份跨进程的缓存,命中时一个库 2–3 ms。是否命中不受 candle 控制,同一个二进制第二次运行通常是热的,但不保证。这层缓存的机制和更多实测归 Metal 后端讲。 - 怎么避开。 服务进程启动后先用一个短 prompt 跑一遍生成,让 prefill 和 decode 两条路上的库都编译一次;只做 prefill 的预热会漏掉 Sdpa。
CANDLE_METAL_COMPUTE_PER_BUFFER 设成 1(每个 op 就提交一次)或 100000(一个 token 里永不因计数提交),decode 每步会变成多少?默认是 50。先猜,再看文末。动脑筋答案
release,热缓存,每个设置跑一个新进程,看同一进程第二轮的 20 个 decode 步:
CANDLE_METAL_COMPUTE_PER_BUFFER=1
[metal round 2] read gguf 128.4 ms, build model 75.3 ms, prefill (13 tokens) 22.8 ms, 20 decode steps 231.4 ms (mean 11.6 ms), total 254.2 ms
CANDLE_METAL_COMPUTE_PER_BUFFER=10
[metal round 2] read gguf 128.8 ms, build model 77.9 ms, prefill (13 tokens) 13.3 ms, 20 decode steps 53.0 ms (mean 2.7 ms), total 66.3 ms
CANDLE_METAL_COMPUTE_PER_BUFFER=50
[metal round 2] read gguf 127.4 ms, build model 75.4 ms, prefill (13 tokens) 10.6 ms, 20 decode steps 47.4 ms (mean 2.4 ms), total 58.0 ms
CANDLE_METAL_COMPUTE_PER_BUFFER=200
[metal round 2] read gguf 128.3 ms, build model 78.9 ms, prefill (13 tokens) 10.9 ms, 20 decode steps 53.6 ms (mean 2.7 ms), total 64.4 ms
CANDLE_METAL_COMPUTE_PER_BUFFER=100000
[metal round 2] read gguf 130.0 ms, build model 75.6 ms, prefill (13 tokens) 12.3 ms, 20 decode steps 67.3 ms (mean 3.4 ms), total 79.6 ms
- 每个 op 提交一次(1):每步 11.6 ms,比 CPU 还慢。一个 decode 步要过 30 层,每层要编码二十个左右的内核(投影、rope、拼 KV、sdpa、归一化、MLP、残差),几百次提交和调度的开销叠起来比算本身还多。
- 10 到 200 之间都差不多(2.4–2.7 ms)。 默认的 50 落在这一段里。
- 永不因计数提交(100000):3.4 ms,反而慢一点。整个 token 攒在一个命令缓冲区里,要等
to_scalar时才一次提交,GPU 在这之前一直闲着。合理的解释是:分批提交时,CPU 还在编码后面的层,GPU 已经开始算前面的层,两边重叠。 - 所以每个 token 读回一次不是问题;问题出在提交得太碎。这个旋钮在
Commands::new里读,提交逻辑见 Metal 后端。
小结
- 推理主线:
Content::read只读头部和目录 →from_gguf按名字seek读出 272 个QTensor→forward对 prefill 和 decode 是同一个函数,按seq_len分岔 →argmax后to_scalar读回一个 token。 - 读 SmolLM2 的 GGUF 头部要 130 ms,因为整份词表都在元数据里;套一层
BufReader降到 31 ms。 - Metal 上 decode 走
sdpa融合内核、QMatMul走矩阵乘向量内核,prefill 走 matmul + softmax 和矩阵乘矩阵内核;两条路的内核不同。 - 热缓存下 Metal 每个 decode 步约 2.5 ms,CPU 约 8 ms;冷缓存时建模型、prefill、第一个 decode 步各编译一批
.metal库,Gemm、Reduce、Sdpa 三个就要 10 s,这就是 Metal 第一次比 CPU 慢的全部原因。 - 每 50 个 op 提交一次的默认值合适:每个 op 都提交慢 4 倍多,攒到读回时才提交也更慢。