Rust in Everything
基于 candle-core 0.11.0

怎么读这一卷

主线位置:三条主线的总览。本页把算子主线、求导主线、推理主线各列一遍,标出每一步落在哪个函数上、哪一页讲它;再讲 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 前缀
不在工作区里的 cratecandle-metal-kernels、candle-kernels、candle-onnx、两个 flash-attn crate在 [workspace] exclude 里,各自的 Cargo.toml 也写 version = "0.11.0"
实验依赖里的 gemm / tokenizers / half / objc2-metal0.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::zeroscandle-core/src/tensor.rsTensor 的结构、错误
2. self.storage() 取 Arc<RwLock<Storage>> 的读锁,连同两边的 Layout 交下去tensor.rsTensor 的结构、布局与视图
3. Storage::matmul 先 same_device、same_dtype,再按 (Cpu, Cpu)、(Cuda, Cuda)、(Metal, Metal) 匹配candle-core/src/storage.rsStorage 与后端 trait、DType 与 Device
4. CPU:CpuStorage::matmul 交给 MatMul(bmnk).map(..);impl Map2 for MatMul 按 feature 有三份 f:gemm crate、Accelerate 的 sgemm、MKLcpu_backend/mod.rs、accelerate.rsCPU 后端
5. Metal:MetalStorage::matmul 分配输出缓冲区,call_mlx_gemm 按形状选 tile、取管线,编码进当前命令缓冲区就返回metal_backend/mod.rs、candle-metal-kernelsMetal 后端
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 为真的张量,可以原地 setcandle-core/src/variable.rs计算图怎么记下来
2. 每个算子用 BackpropOp::new1/new2/new3 决定记不记:track_op 为真(输入是变量或者自己带着 Op)才把输入张量的 Arc 存进结果的 BackpropOpop.rs、tensor.rs计算图怎么记下来
3. Tensor::backward 先 sorted_nodes 收集通向变量的节点,再倒序逐个套 Op 的求导规则candle-core/src/backprop.rsbackward 与 GradStore
4. 梯度累加进 GradStore(HashMap<TensorId, Tensor>),grads.get(&var) 按 id 取backprop.rsbackward 与 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.rsGGUF 文件
2. ModelWeights::from_gguf 从元数据取超参数,按 blk.{i}.attn_q.weight 这样的名字用 Content::tensor 读出 QTensor,包成 QMatMulcandle-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_dimquantized_llama.rs、candle-nn/src/ops.rsKV 缓存与注意力、常用层
5. MLP 的三个投影是 QMatMul:权重保持量化,QMatMul::forward 经 apply_op1_no_bwd 交给 QTensor 这个自定义算子candle-core/src/quantized/mod.rsQTensor 与 QMatMul、量化格式
6. 最后一个位置的隐状态乘输出矩阵得到 logits;LogitsProcessor::sample 按温度、top-k、top-p 取样,或者直接 argmaxcandle-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打开什么本卷
metalobjc2-metal、objc2-foundation、candle-metal-kernels;lib.rs 编译 metal_backend 并导出真的 MetalDevice / MetalStorage;开了 ug 时顺带 candle-ug?/metal实测
cudacudarc、candle-kernels(构建时用 nvcc 编 .cu,构建机要有 CUDA 工具链);编译 cuda_backend只读源码
cudnncuda 加 cudarc/cudnn,导出 cuda_backend::cudnn,卷积可以走 cuDNN只读源码
ncclcuda 加 cudarc/nccl,多卡通信只读源码
accelerateaccelerate-src(构建脚本只有一行 cargo:rustc-link-lib=framework=Accelerate);编译 accelerate.rs:matmul 换成 BLAS 的 sgemm_ / dgemm_,exp、tanh、gelu 等逐元素函数和 vec_add 一类换成 vDSP / vForce实测
mklintel-mkl-src;编译 mkl.rs,作用同 accelerate,面向 x86只读源码
metal-debug-labelsmetal 加 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 的开销见错误。

📝NOTE
没有蠢问题

问: 每个 + 都返回 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 设计取舍。

阅读路线

前置知识

  • 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> 自己也实现了运算符,一条链只需要一个 ?。

💬 关联讨论

发起讨论
加载中...