Rust in Everything
基于 candle-core 0.11.0

生成一个 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 步用到的内核集合不一样,这一点在下一节决定了第一次运行的时间分布。

📝NOTE
冷知识:decode 的 sdpa 分支是和 Metal 内核一起进来的

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 ms78–117 msCast 78、Gemm 3829(precomput_freqs_cis 里的一次 matmul)、Unary 114
prefill(13 个 token)5869 ms20–43 msIndexing 71、Reduce 4334(rmsnorm、softmax、rope、argmax 都在里面)、Quantized 789、Affine 26、Ternary 44、Binary 567
第 1 个 decode 步2144 ms2.5–6 msSdpa 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。
❗IMPORTANT
动脑筋:热缓存下,把环境变量 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 倍多,攒到读回时才提交也更慢。

💬 关联讨论

发起讨论
加载中...