怎么读这一卷
主线位置:三条主线的总览。本页把算子主线、求导主线、推理主线各列一遍,标出每一步落在哪个函数上、哪一页讲它;再讲 Cargo feature 怎么决定这三条线上有哪些代码:没开
metal/cuda时,Storage的那个变体由 dummy 类型占位,只会返回错误。「一次 matmul」和「生成一个 token」用实验把算子主线和推理主线各走一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1[01 全景]
use1 --> use2["02 张量 和 07 模型"]
q -->|读懂实现| all[00 到 11 按顺序]
q -->|只关心 Metal| gpu["一次 matmul 再到 03 Metal"]
q -->|自己写一个| hands["06 量化 再到 11 动手"]
锁定版本
| 项 | 本卷 | 说明 |
|---|---|---|
| candle 工作区 | 0.11.0,tag 0.11.0,commit 31f35b14(2026-06-26) | [workspace.package] version = "0.11.0",edition 2021;tag 没有 v 前缀 |
| 不在工作区里的 crate | candle-metal-kernels、candle-kernels、candle-onnx、两个 flash-attn crate | 在 [workspace] exclude 里,各自的 Cargo.toml 也写 version = "0.11.0" |
| 实验依赖里的 gemm / tokenizers / half / objc2-metal | 0.19.0 / 0.22.2 / 2.7.1 / 0.3.2 | 锁文件里实际解析到的版本;锁文件里还有 gemm 0.18.2,属于可选依赖 candle-ug → ug 那条链,没开 ug 时不参与编译 |
| 上一版 | 0.10.2(2026-04-02)、0.10.0(2026-03-31) | 本地仓库里最早的 tag 是 0.5.1,更早的提交查不到首次发布的版本 |
源码链接一律指向 tag:https://github.com/huggingface/candle/blob/0.11.0/...。crates.io 上发布的 candle-*-0.11.0 和 tag 是同一份代码。仓库的合并多是 squash,提交标题末尾的 (#NNNN) 就是 PR 号;CHANGELOG.md 停在 v0.3,近两年的改动只能从提交记录里查,本卷的「冷知识」都从这里来。
实测平台是 M2 Max、macOS 27、rustc 1.99.0。GPU 只实测了 Metal;CUDA 后端(cuda_backend/、candle-kernels)只读源码,凡是只读了源码的地方都会写明。
三条主线
全卷沿三条线展开,每页开头都会标出本页在哪条主线上。
算子主线:一次 a.matmul(&b)?
flowchart TB mm["Tensor matmul"] --> chk["形状检查 算 bmnk"] chk --> lock["storage 读锁"] lock --> st["Storage matmul"] st --> same["同设备 同 dtype"] same -->|Cpu Cpu| cpu["gemm 或 sgemm"] same -->|Metal Metal| mtl["编码 MLX gemm"] cpu --> out["新 Tensor 带 BackpropOp"] mtl --> out out --> read["to_vec2 读回"] read -->|Metal| wait["提交并等待"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. Tensor::matmul 要求两边秩相同且至少为 2,算出批维乘积和 (m, n, k),k 或批维对不上就返回 ShapeMismatchBinaryOp;结果元素数为 0 时直接返回 Tensor::zeros | candle-core/src/tensor.rs | Tensor 的结构、错误 |
2. self.storage() 取 Arc<RwLock<Storage>> 的读锁,连同两边的 Layout 交下去 | tensor.rs | Tensor 的结构、布局与视图 |
3. Storage::matmul 先 same_device、same_dtype,再按 (Cpu, Cpu)、(Cuda, Cuda)、(Metal, Metal) 匹配 | candle-core/src/storage.rs | Storage 与后端 trait、DType 与 Device |
4. CPU:CpuStorage::matmul 交给 MatMul(bmnk).map(..);impl Map2 for MatMul 按 feature 有三份 f:gemm crate、Accelerate 的 sgemm、MKL | cpu_backend/mod.rs、accelerate.rs | CPU 后端 |
5. Metal:MetalStorage::matmul 分配输出缓冲区,call_mlx_gemm 按形状选 tile、取管线,编码进当前命令缓冲区就返回 | metal_backend/mod.rs、candle-metal-kernels | Metal 后端 |
6. BackpropOp::new2 只在某个输入需要梯度时记下 Op::Matmul(lhs, rhs),from_storage 包成新张量 | op.rs、tensor.rs | 计算图怎么记下来 |
7. 读回:to_vec2 在 Metal 上走 MetalStorage::to_cpu:编码一次 blit,flush_and_wait_current 提交并等这个命令缓冲区 | metal_backend/mod.rs、candle-metal-kernels/src/metal/commands.rs | 一次 matmul、Metal 后端 |
第 5 步和第 7 步的分界值得记住:在 Metal 上,a.matmul(&b)? 返回时内核只是编码进了命令缓冲区,GPU 可能还没开始算;读数据或 device.synchronize() 时才提交、等待。另一个时刻是攒够数:每取 50 次编码器提交一次。CPU 上第 4 步返回时结果已经算完。实验见一次 matmul。
求导主线:一次 loss.backward()?
flowchart TB var["Var new"] --> fwd["每个 op 记 Op"] fwd --> bw["Tensor backward"] bw --> topo["sorted_nodes 拓扑排序"] topo --> rev["倒序套求导规则"] rev --> gs["GradStore 按 TensorId"] gs --> opt["优化器按 Var 取梯度"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. Var 包着一个 is_variable 为真的张量,可以原地 set | candle-core/src/variable.rs | 计算图怎么记下来 |
2. 每个算子用 BackpropOp::new1/new2/new3 决定记不记:track_op 为真(输入是变量或者自己带着 Op)才把输入张量的 Arc 存进结果的 BackpropOp | op.rs、tensor.rs | 计算图怎么记下来 |
3. Tensor::backward 先 sorted_nodes 收集通向变量的节点,再倒序逐个套 Op 的求导规则 | candle-core/src/backprop.rs | backward 与 GradStore |
4. 梯度累加进 GradStore(HashMap<TensorId, Tensor>),grads.get(&var) 按 id 取 | backprop.rs | backward 与 GradStore |
5. candle-nn 的 Optimizer 的 backward_step 调 backward 再 step;SGD、AdamW 按 Var 取梯度、原地 set;VarMap 按名字收集变量 | candle-nn/src/optim.rs、var_map.rs | 优化器与训练循环 |
求导不是单独的一层:图就是张量之间的 Arc 引用,和算子主线是同一批调用。
推理主线:生成一个 token
flowchart TB read["Content read"] --> build["ModelWeights from_gguf"] build --> fwd["forward input pos"] fwd --> emb["Embedding"] emb --> norm["RmsNorm"] norm --> attn["注意力 加 KV 缓存"] attn --> mlp["MLP 用 QMatMul"] mlp --> logits["logits"] logits --> pick["取样"] pick -->|下一个 token| fwd
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. gguf_file::Content::read 读魔数、版本、元数据(连整份词表)、张量目录,记下数据区起点 | candle-core/src/quantized/gguf_file.rs | GGUF 文件 |
2. ModelWeights::from_gguf 从元数据取超参数,按 blk.{i}.attn_q.weight 这样的名字用 Content::tensor 读出 QTensor,包成 QMatMul | candle-transformers/src/models/quantized_llama.rs | 量化 llama 的一次前向、QTensor 与 QMatMul |
3. ModelWeights::forward:输入多于一个 token 时取(缓存的)因果 mask;embedding 是反量化后的普通张量 | quantized_llama.rs | 量化 llama 的一次前向、常用层 |
4. 每层先 RmsNorm(融合算子 rms_norm),再 forward_attn:q/k/v 投影、rotary、和 KV 缓存 cat;Metal 上单 token 时走 sdpa,否则 matmul + mask + softmax_last_dim | quantized_llama.rs、candle-nn/src/ops.rs | KV 缓存与注意力、常用层 |
5. MLP 的三个投影是 QMatMul:权重保持量化,QMatMul::forward 经 apply_op1_no_bwd 交给 QTensor 这个自定义算子 | candle-core/src/quantized/mod.rs | QTensor 与 QMatMul、量化格式 |
6. 最后一个位置的隐状态乘输出矩阵得到 logits;LogitsProcessor::sample 按温度、top-k、top-p 取样,或者直接 argmax | candle-transformers/src/generation/mod.rs | 取样 |
safetensors 那条路换掉前两步:VarBuilder::from_mmaped_safetensors 按名字取张量,模型用 candle_transformers::models::llama 或 bert,见 VarBuilder、safetensors 与 mmap、BERT。分阶段计时见生成一个 token。
feature 各打开什么
candle-core 的默认 feature 是空的:不开任何 feature 时只有 CPU 后端,matmul 走纯 Rust 的 gemm crate。candle-nn、candle-transformers 有同名 feature,只是往下转发。candle-core/Cargo.toml 的 [features]:
| feature | 打开什么 | 本卷 |
|---|---|---|
metal | objc2-metal、objc2-foundation、candle-metal-kernels;lib.rs 编译 metal_backend 并导出真的 MetalDevice / MetalStorage;开了 ug 时顺带 candle-ug?/metal | 实测 |
cuda | cudarc、candle-kernels(构建时用 nvcc 编 .cu,构建机要有 CUDA 工具链);编译 cuda_backend | 只读源码 |
cudnn | cuda 加 cudarc/cudnn,导出 cuda_backend::cudnn,卷积可以走 cuDNN | 只读源码 |
nccl | cuda 加 cudarc/nccl,多卡通信 | 只读源码 |
accelerate | accelerate-src(构建脚本只有一行 cargo:rustc-link-lib=framework=Accelerate);编译 accelerate.rs:matmul 换成 BLAS 的 sgemm_ / dgemm_,exp、tanh、gelu 等逐元素函数和 vec_add 一类换成 vDSP / vForce | 实测 |
mkl | intel-mkl-src;编译 mkl.rs,作用同 accelerate,面向 x86 | 只读源码 |
metal-debug-labels | metal 加 candle-metal-kernels/debug-labels,给命令队列、缓冲区打标签,给编码器加 debug group | 不展开 |
ug | 可选依赖 candle-ug(ug crate 的内核生成);MetalDevice::compile 和 UgIOp1 只在它打开时存在 | 不展开 |
两点从源码里能直接看到:
accelerate换掉的不只是速度。Accelerate 那份f只处理 F32 和 F64,遇到 F16 直接bail!;gemm那份接受 F16、F32、F64。BF16 两份都不接受。同一个 2×2 的ones矩阵乘,两种构建(intro-mm开accelerate,intro-mm-noacc不开,release):intro-mm cpu F32: ok [[2.0, 2.0], [2.0, 2.0]] intro-mm cpu F64: ok [[2.0, 2.0], [2.0, 2.0]] intro-mm cpu F16: Err(the accelerate backend does not support f16 matmul) intro-mm cpu BF16: Err(unsupported dtype BF16 for op matmul) intro-mm-noacc cpu F32: ok [[2.0, 2.0], [2.0, 2.0]] intro-mm-noacc cpu F64: ok [[2.0, 2.0], [2.0, 2.0]] intro-mm-noacc cpu F16: ok [[2.0, 2.0], [2.0, 2.0]] intro-mm-noacc cpu BF16: Err(unsupported dtype BF16 for op matmul)打开一个 feature,F16 的 matmul 就从能用变成报错。速度上的差别(1024² f32 快约 4 倍)见一次 matmul 和 CPU 后端。
README 的 Common Errors 一节写着:开
accelerate/mkl时如果链接报_sgemm_找不到,在二进制里加一行extern crate accelerate_src;。candle-core的lib.rs自己已经有#[cfg(feature = "accelerate")] extern crate accelerate_src;,本卷的实验在 macOS 上开accelerate都没有额外设置就链接成功了。
没开 metal / cuda 时:dummy 后端
Storage 永远是 Cpu、Cuda、Metal 三个变体。没开 feature 时,lib.rs 把 dummy_metal_backend 里的单元结构体当作 MetalDevice / MetalStorage 导出,CUDA 同理。它们的方法要么返回 Err(NotCompiledWithMetalSupport),要么调 fail!(unimplemented!)。细节见 Storage 与后端 trait。
intro-tree 只依赖 candle-core、不开任何 feature(debug)。正常的入口 Device::new_metal 返回 Err;但 dummy 的 MetalDevice 是公开的单元结构体,可以手写 Device::Metal(candle_core::MetalDevice) 绕过去,这时建张量仍然是 Err,问设备的 location() 就是 panic:
let fake = Device::Metal(candle_core::MetalDevice);
match Tensor::zeros((2, 2), DType::F32, &fake) { /* ... */ }
println!("is_metal = {}", fake.is_metal());
println!("location = {:?}", fake.location());
new_metal: Err(the candle crate has not been built with metal support)
new_cuda: Err(the candle crate has not been built with cuda support)
zeros on dummy: Err(the candle crate has not been built with metal support)
is_metal = true
thread 'main' (3735263) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/dummy_metal_backend.rs:242:9:
not implemented: metal support has not been enabled, add `metal` feature to enable.
正常写法碰不到这个 panic;它说明的是 dummy 后端的设计:代码路径一条不少,只在运行期失败。好处是 storage.rs 里的 match 永远写三支、不用到处加 #[cfg];代价是「没开 metal」要到运行期才知道。
错误风格:几乎处处 Result
candle-core 只有一个 Error 枚举和一个 Result<T> 别名。tensor.rs 里手写的 139 个 pub fn 方法中,121 个返回 Result;剩下的 18 个是 dtype、shape、dims、layout、id、is_contiguous 这类读字段的访问器,strided_index 这类遍历器的构造,加上 detach 和 from_storage。连运算符也不例外:bin_trait! 给 Tensor、&Tensor 实现的 Add、Mul 等,Output 都是 Result<Tensor>。所以 README 的速查表里写的是 &a + &b,用的时候是 (&a + &b)?。
形状不对是一个 Err 值,不是 panic(intro-tree 的 shape,debug):
Err: shape mismatch in matmul, lhs: [2, 3], rhs: [2, 3]
错误在构造处调 Error::bt,它用 Backtrace::capture(),所以设了 RUST_BACKTRACE=1(或 RUST_LIB_BACKTRACE=1)时同一行后面会跟着整段调用栈,第 4、5 帧就是 Error::bt 和 Tensor::matmul。报错原文、bail!、Context 和 backtrace 的开销见错误。
问: 每个 + 都返回 Result,a + b + c 岂不是要写成 ((&a + &b)? + &c)??
答: 不用。bin_trait! 还给 Result<B> 实现了同样的运算符(impl Add<&Tensor> for Result<B>),左边已经是 Err 时直接把它传下去。所以一条链只在最后写一个 ?,第一个错误原样带到末尾(intro-tree 的 chain,debug):
let ok = (&a + &b + &a)?;
let chained = &a + &bad + &b; // bad 的形状是 [3]
ok = [12.0, 24.0]
chained = Err(shape mismatch in add, lhs: [2], rhs: [3])
报错里的 add 是第一个 +;第二个 + 没有执行,只是转交了错误。
和 burn 的对照只说一句:burn 的张量方法返回张量本身,形状和设备的错误是 panic,见 burn 卷的检查与报错;两种做法的取舍放在 08 设计取舍。
阅读路线
- 只想会用:读 01 全景;02 的 DType 与 Device 和错误;05 的 VarBuilder;07 的量化 llama 的一次前向和取样。
- 想读懂实现:按顺序读。02、03 讲清楚一次调用怎么从
Tensor落到哪个后端之后,04 的求导和 06 的量化才好读。 - 只关心 Metal:一次 matmul → Metal 后端 → 自定义算子 → QTensor 与 QMatMul。
- 想自己写一个:读完 06 量化,去 11 动手,不依赖 candle 写 Q8_0 量化和 GGUF 头部解析。
前置知识
- Rust 的 trait、枚举和
?:后端是枚举加两个 trait,见 Storage 与后端 trait。 - 一点深度学习:张量、矩阵乘、反向传播(backpropagation)、Transformer 的注意力和 KV 缓存。不需要会 PyTorch;README 的速查表把常用的 PyTorch 写法和 candle 写法一一对上了,见 candle 解决什么问题。
- serde:safetensors 的头部是 JSON,见 serde 卷。
- tracing:
candle-transformers的模型里到处是tracing::span!,要配 subscriber 才看得到,见 tracing 卷。
小结
- 全卷对着 candle 0.11.0(tag
0.11.0,31f35b14,2026-06-26)写;GPU 只实测 Metal,CUDA 只读源码。 - 算子主线:
Tensor::matmul→ 读锁 →Storage::matmul按设备对match→gemm/sgemm当场算完,或 MLX gemm 编码进命令缓冲区 → 新张量带BackpropOp→ Metal 读回时提交并等待。 - 求导主线:
Var→ 每个 op 视需要记Op→sorted_nodes→ 倒序求导 →GradStore→ 优化器。 - 推理主线:
Content::read→ModelWeights::from_gguf→forward(RmsNorm、注意力加 KV 缓存、QMatMul)→ logits → 取样。 - 默认 feature 只有 CPU;
accelerate换了 matmul 的实现,也让 F16 matmul 变成报错;没开的 GPU 后端由 dummy 类型占位,运行期报错。 - 几乎所有张量方法和运算符都返回
Result;Result<Tensor>自己也实现了运算符,一条链只需要一个?。