Rust in Everything
基于 candle-core 0.11.0

设计取舍

主线位置:三条主线的汇总。算子主线上的取舍是「后端是枚举」「即时执行、不融合」「Result 到处都是」「Arc<RwLock<Storage>>」「手写内核、运行期编译」;求导主线上的是「自动微分内建在 Tensor 里」和「训练支持很薄」;推理主线上的是「为推理而生」。每一条只说收益和代价,机制在链接指向的页面里。

flowchart TB
  t["Tensor"] -->|每 op 一次 match| s["Storage 枚举"]
  t --> lock["RwLock 存储"]
  s --> cpu["CPU 当场算完"]
  s --> mtl["Metal 编码即返回"]
  mtl -->|一 op 一内核| fused["手写融合算子"]
  mtl -->|首次整文件编译| lib[".metal 库"]
  fused --> infer["GGUF 量化缓存"]
  t -->|训练| opt["SGD 与 AdamW"]

每一节回答三个问题:candle 选了什么,换来了什么,付出了什么。依据只有三种:本卷各页跑出来的数,candle 0.11.0 tag 上的源码和提交记录,burn 卷的页面和数字(burn v0.22.1)。最后一节是本页补的实验:同一台机器、同一组小负载,candle 和 burn 各跑一遍。平台 M2 Max、macOS 27、rustc 1.99.0;GPU 只测了 Metal,CUDA 只读源码。

1. 后端是枚举,不是运行期分派加装饰器

选择。 Storage 和 Device 都是 Cpu、Cuda、Metal 三个变体的枚举,每个方法先查同设备、同 dtype,再对设备对 match。BackendStorage 是要求 Sized 的编译期契约,不当 trait 对象用;没开的后端由 dummy 类型占位(Storage 与后端 trait)。自动微分不是一层,而是 Tensor_ 里的 op 字段(计算图怎么记下来)。burn 0.22 也是对设备 match,但分支后面是一整套 Backend 实现,Autodiff<B>、Fusion<B> 作为装饰器套在外面(Dispatch)。

换来。

  • 层少。CPU 上一次 matmul 从用户代码到 gemm 只经过 4 个 candle 函数(一次 matmul)。本页第 8 节量了同一个 16 元素 f32 加法:candle 每次 122–128 ns,burn(只用 Flex,开着 metal 和默认的 autodiff)237–239 ns。
  • 二进制小。只做一次 CPU matmul 的程序 0.92 MB,开 metal 是 1.77 MB,多出来的主要是嵌进去的 617765 字节 .metal 源码;burn 的同类程序开 metal 从 6.7 MB 变成 105 MB(candle 解决什么问题、burn 卷的设计取舍)。
  • 设备不一致是一个 Err,不是 panic(第 3 节)。

付出。

  • 后端集合是封闭的。Storage 是公开的枚举,用户 crate 加不了变体;想加一个后端,要改 candle-core 的三个枚举和每一处 match。用户能扩展的只有单个算子:CustomOp1/2/3 为每个设备各写一个前向(自定义算子)。
  • 自动微分和融合不能「套上去」。burn 的自动微分写一遍对所有后端成立,融合写一遍对所有 cubecl 运行时成立;candle 的自动微分写死在 Tensor 里,融合只能逐个算子手写(第 2 节)。
  • 「没开 metal」要到运行期才知道:Device::new_metal 返回 Err;手写出 dummy 的 Device::Metal(..) 再问 location() 是 panic(怎么读这一卷)。
  • 「同一设备」按实例判断:两次 new_metal(0) 是两个设备,互相不能运算(DType 与 Device)。

2. 即时执行、不融合,靠手写融合算子补

选择。 每个 op 立即执行:CPU 上调用返回时已经算完;Metal 上把一个内核编码进共享的计算编码器就返回,每取用编码器 50 次提交一次命令缓冲区(Metal 后端)。没有惰性图,也没有融合器。要融合,就写一个 CustomOp:candle-nn 的 softmax_last_dim、rms_norm、layer_norm、rope、sdpa 都是每个设备一个手写内核(常用层)。burn 的 cubecl 后端默认套着 Fusion,op 先登记、读数据时再把能合的合成一个内核(Fusion 装饰器)。

换来。

  • 行为在调用处:形状错在那一行报,CPU 的计时就是那一行的时间;Metal 上只要在结束处 synchronize,量到的就是 GPU 的活(一次 matmul)。没有「丢掉的结果不算」「错误读时才出」这类惰性执行的规则。
  • 编码便宜:默认设置下每个小 op 在 CPU 上约 1.4–1.6 µs(Metal 后端)。
  • 手写的融合算子在 Metal 上很快:prefill 形状下 softmax 0.199 ms 对拼接版本 1.781 ms,各算子快 4 到 16 倍(常用层)。推理热路径上的 RmsNorm、rope、softmax、decode 时的注意力都有对应的融合内核,所以 Metal 上一个 decode 步约 2.5 ms(生成一个 token)。

付出。

  • 没手写融合的地方,一个 op 一个内核、一次读写整块显存。第 8 节在 Metal 上跑了 burn 卷用过的两串逐元素 op(4096² f32):8 个逐元素 op,candle 每次 3.21–3.39 ms,burn 融合成一个内核后 0.52 ms;(a * b + c).exp(),candle 1.87–1.90 ms,burn 0.80–0.90 ms。candle 的数和 burn 关掉 fusion 时的 3.03 ms 同一量级(Fusion 装饰器)。
  • 融合算子没有反向。它们都经 apply_op*_no_bwd 调用,梯度在这里静默中断;RmsNorm::forward 在连续输入上拿不到梯度,训练要换 forward_diff 或拼接版本(自定义算子)。融合算子还大多要求输入连续。
  • 小张量、op 多的训练步在 Metal 上是 CPU 一侧的开销说了算。第 8 节的 MLP 训练步不读回时每步约 780 µs,其中约 700 µs 是循环本身的 CPU 时间。用 sample 对 Metal 上的训练循环采样 3 秒,主线程 1324 个样本里 backward 占 833 个;其中 380 个在 GradStore::or_insert 调的 zeros_like 里:每个梯度先建一个全零张量再往上加,Metal 上 allocate_zeros 用一次 blit 填零,而 Commands::blit_command_encoder 每次都要先结束当前的计算编码器、新建一个 fence 并等待之前的 fence。取 blit 编码器一项合计 506 个样本(还包括 Var::set 把新参数拷回去的那次)。即时执行没有机会把「建零、相加」合成一步。

3. Result 到处都是

选择。 candle-core 一个 Error 枚举、一个 Result<T> 别名;tensor.rs 里手写的 139 个 pub fn 有 121 个返回 Result,运算符的 Output 也是 Result<Tensor>(怎么读这一卷)。burn 的张量方法返回张量本身,形状和设备不对在 TensorCheck 里 panic,Result 只出现在读数据、同步和设备设置上(检查与报错)。

换来。

  • 形状、dtype、设备不匹配都是值,报错原文固定:shape mismatch in add, lhs: [2, 3], rhs: [3]、device mismatch in add, ..(错误)。一个推理服务可以把坏输入变成一个错误响应,不必 catch_unwind。
  • 库内部全是 ?:candle-core、candle-nn、candle-transformers 的 src/ 里找不到按 Error 变体分支的 match(错误)。Result<Tensor> 自己也实现了运算符,一条表达式只在末尾写一个 ?。

付出。

  • 每个调用都多一个 ?,连 t()、narrow 这种只算布局的方法也是。
  • 默认不知道错在哪一行。backtrace 由 RUST_BACKTRACE / RUST_LIB_BACKTRACE 决定,打开后每个错误多约 11 µs,错误被包进 WithBacktrace,按变体 match 会失配;Option::context 还会包两层(错误)。
  • Result 不是全部。本卷实测碰到的 panic:整数张量的一元运算是 todo!()(Storage 与后端 trait);new_metal(1) 在只有一块 GPU 时越界(错误);秩超过 8 的二元运算(布局与视图);from_vec 元素个数不对,CPU 上读数据时切片越界(错误);自定义算子的 bwd 对中间结果返回 None,backward 里「grad not populated」(backward 与 GradStore);general.alignment = 0 的 GGUF 除以零(GGUF 文件);Metal 上 f16 输入、多行的 QMatMul 断言失败(QTensor 与 QMatMul);RawInterleavedKvCache 容量为 0 时写入(KV 缓存与注意力)。另有一种既不是 Err 也不是 panic 的:t.inplace_op2(&t, ..) 永远卡住(第 4 节)。

4. 数据放在 Arc<RwLock<Storage>> 里

选择。 Tensor 是 Arc<Tensor_>,数据在第二层 Arc<RwLock<Storage>>。注释里列了 Mutex、RefCell、UnsafeCell 三种候选,RwLock 取中间(Tensor 的结构)。

换来。

  • clone 是一次原子加,4 ns;视图只新建 Tensor_、共享 storage,t() 约 50 ns,和数据量无关。计算图把输入存成 Tensor 几乎没有成本(Tensor 的结构)。
  • a + a 两边各取一次读锁,不会自锁;Tensor 仍是 Send + Sync。
  • 参数可以原地改:Var::set 取写锁把新值拷进原 storage,id 不变,所以优化器不用重建模型,GradStore 直接按张量 id 查梯度(优化器与训练循环)。burn 的 ModuleOptimizer::step 返回换上新参数的新模块,要先把节点 id 换成 ParamId。

付出。

  • 写锁和读锁碰上同一把锁就死锁。inplace_op2 先取 self 的写锁再取 rhs 的读锁,a.inplace_op2(&a, ..) 没有报错、没有 panic,进程停在那里;slice_set、scatter_set、Var::set 先用 same_storage 挡住这种情况,inplace_op* 没有挡(Tensor 的结构、自定义算子)。
  • 「复制」的语义要自己记。经视图写入会改到原张量;CPU 上 copy() 复制整块 storage,对 100 万元素张量的 2 元素视图调 copy 得到 100 万元素;Metal 上 copy() 只复制 Arc<Buffer>,原地写副本会改到原张量,这是 0.11.0 的上游 bug(Tensor 的结构、Metal 后端)。
  • 算子不原地算。图里存着输入张量,原地写会改掉反向要用的值,所以每个算子分配新 storage;CPU 后端没有缓冲池,每个运算一个新 Vec(CPU 后端)。
  • 每个 op 两边各取一次读锁,取锁后 unwrap()。这一项本卷没有单独量;第 8 节 16 元素加法的 122–128 ns 包括了它。

5. 手写内核,运行期整文件编译

选择。 Metal 内核是 16 个 .metal 文件,include_str! 进二进制,进程里第一次用到某个文件时由 load_library 交给 newLibraryWithSource 整个编译,按文件和管线两级缓存(Metal 后端)。内核不全是自己写的:mlx_gemm.metal 的头部注释写着「MLX Kernel extracted from」,scaled_dot_product_attention.metal 写着「Updated from MLX commit」,quantized.metal 沿用 ggml 的 GGML_TABLE_BEGIN 宏。GEMM 的分块参数由 select_tile_config 按设备档次、形状、dtype 查规则选,注释逐条对着 MLX 的选法,没有 autotune。CUDA 内核是 .cu,candle-kernels/build.rs 在构建时用 nvcc 编(本卷只读源码)。burn 的内核是 cubecl 的 #[cube] 函数,运行期生成 MSL 再编译,matmul 第一次遇到某个调优键时实测挑候选(autotune)。

换来。

  • 不维护编译器和中间语言;GPU 代码就是 MSL / CUDA C++,可以直接搬 MLX、llama.cpp 的内核。
  • 热启动便宜。macOS 把编译好的库缓存在用户缓存目录里,命中时每个库 2–3 ms;第 8 节新进程里第一次 1024² matmul 3.70–3.73 ms,burn 是 81–87 ms(autotune 结果已在磁盘上,但每个新进程要重新生成并编译 MSL,见 wgpu 与 Metal)。MLP 的第一个训练步:candle 18.7–19.5 ms,burn 174–186 ms。
  • 二进制里多的只是源码文本(第 1 节)。

付出。

  • 冷启动按库付,而且由 macOS 的缓存决定,candle 控制不了。缓存不命中时 mlx_gemm.metal 4.0 s、reduce.metal 4.5 s(Metal 后端);生成一个 token 的冷启动三处编译合计约 12 s(生成一个 token)。burn 的冷启动是另一笔:空的 autotune 缓存下第一次 1024² matmul 3.67 s(autotune),本页第 8 节的第一个进程是 4.24 s,同一个进程里 MLP 训练步的第一步 4.55 s。两边的冷启动都在秒级,热启动差一个数量级多。
  • 每个算子每个后端手写一遍:CPU 一个函数、Metal 一个内核、CUDA 一个内核。覆盖不齐的地方只能在运行期发现:Metal 上 I16、I32、F64 只能上传下载(DType 与 Device),Q8K 等格式缺 mv 内核(QTensor 与 QMatMul);newLibraryWithSource 没有包含路径,同一个辅助函数在六个 .metal 文件里各抄一份(Metal 后端)。
  • 不调优:分块规则是照 MLX 抄的固定表,换一块 GPU 不会自己变。第 8 节 Metal 上 1024² matmul 两边的中位数相近(candle 1.15–1.38 ms,burn 1.30–1.85 ms),这个尺寸上固定表没吃亏;别的尺寸本页没有量。
  • 用 CUDA 要在构建机上装 nvcc。
📝NOTE
冷知识:candle 也有过运行期生成内核的入口

PR #2579「Support for UG kernels.」(2024-10,首次发布在 0.8.0)给 candle-core 加了 UgIOp1:用 ug crate 的 SSA 形式描述一个内核,在 CUDA 或 Metal 设备上运行期编译成管线,作为原地算子调用。提交说明只有两条:「Support for UG kernels.」「Add a dedicated test.」,diff 里 ug 是 candle-core 的必选依赖。PR #3268「feat!: Make ug dependency optional」(首次发布在 0.10.0)把它收进新的 candle-ug crate,candle-core 加了 feature ug,UgIOp1 只在开了它时导出。0.11.0 里 UgIOp1 的 cpu_fwd 直接 bail!("ug ops are only supported on metal/cuda at the moment"),Metal 前向只接受 f32;整个仓库里用到它的只有 candle-core/tests/custom_op_tests.rs,candle-nn、candle-transformers 都不用。burn 删 burn-candle 时的提交说明提到「gemm 0.18, safetensors 0.4 and zip 7 were reachable only through candle's ug chain」(见 burn 卷的设计取舍),说的也是这个 ug。

6. 为推理而生,训练支持很薄

选择。 推理需要的东西都在库里:GGUF 读写和 15 种 GgmlDType(GGUF 文件、量化格式),CPU / Metal / CUDA 各一套量化矩阵乘 QMatMul(QTensor 与 QMatMul),KvCache 和另外几种缓存、CPU flash attention(KV 缓存与注意力),LogitsProcessor(取样),candle-transformers 里 124 个模型模块、其中 22 个 quantized_*(源码地图)。训练一侧,candle-nn/src/optim.rs 一共 201 行:没有动量的 SGD 和 AdamW,没有学习率调度和梯度裁剪(优化器与训练循环)。

换来。

  • 从一个 GGUF 文件到生成文本,不用别的库:SmolLM2 Q8_0 热缓存下 Metal 每个 decode 步约 2.5 ms,CPU 约 8 ms(生成一个 token);Metal 上一行的 Q4_0 矩阵乘向量比 f32 快 8 倍(QTensor 与 QMatMul)。
  • 量化格式和 llama.cpp 逐字节一致:Q8_0 的量化结果和 GGUF 文件、和本卷手写的 mini 版本都相同(量化格式、写一个迷你 Q8_0 量化)。
  • 训练循环短:模型里的权重就是 Var,没有参数 id 的换算(第 4 节)。小 MLP 的训练步在 CPU 上和 burn 一样快(第 8 节)。

付出。

  • 优化器只有两个。burn 0.22.1 的 burn-optim 有 adam、adamw、adagrad、rmsprop、lamb、lion、muon、lbfgs 等十来种,外加学习率调度、梯度裁剪和梯度累积(burn-optim 的 src/ 约 9900 行,含文件内测试),训练循环和指标在 burn-train 里(约 3 万行)。
  • 训练的细节要自己当心:推理用的融合算子不记反向(第 2 节);CPU 上 set_seed 直接报错,初始化不可复现,Metal 上小于 2^32 的种子被忽略(优化器与训练循环、取样);没有 no_grad,GradStore 里还留着输入 x、y 的梯度,每步多算两个没人要的梯度(优化器与训练循环)。
  • QTensor 不求导,量化路径只服务推理(QTensor 与 QMatMul)。
  • 推理主线上的约定没有检查:quantized_llama 的 index_pos 传错,decode 时静默算错;RoPE 约定按架构名选,选错不报错,输出退化成重复(量化 llama 的一次前向)。

7. 本卷发现的静默算错

前六节的设计选择里,有几处在 0.11.0 上会给出错误的数而不报错。下表只列本卷各页实际复现过的,「上游 bug」指和文档或另一后端的行为不一致,不是设计如此:

现象条件另一边的行为页面
copy() 出来的张量和原张量共用显存MetalCPU 真复制Tensor 的结构、Metal 后端
批量 matmul 读错批次Metal,批维步长合不成一个(如 narrow、permute 之后)CPU 报 MatMulUnexpectedStriding布局与视图、Metal 后端
from_vec 元素少了也建得出张量,求和、加法读越界照常出数MetalCPU 读数据时 panic错误
BF16 softmax 一行 4096 个 0,整行加起来是 16CPU,BF16 / F16 求和用半精度累加,一行超过 256 个相近值Metal 用 F32 累加,正确常用层
set_seed(42) 和不设种子生成同一串随机数Metal,种子小于 2^32(字节序不一致)CPU 的 set_seed 直接报错取样
f16 输入的量化矩阵乘给出错误的数Metal,输入一行多行时 panic;CPU 正确QTensor 与 QMatMul
维度乘积溢出的 GGUF 读出一个存储为空的张量release;只出现在构造的坏文件上debug 下 panicGGUF 文件

另有几处不算 bug、但同样不报错的设计:融合算子没有反向,梯度静默为 None(自定义算子);RmsNorm::forward_diff 的文档注释和行为相反(同一页);VarBuilder 的 dtype 存了两份,to_dtype 只改一份(VarBuilder);克隆的 KvCache 共享缓冲区(KV 缓存与注意力);CPU 的量化矩阵乘先把激活量化成 Q8_0,同一个 GGUF 在 CPU 和 Metal 上生成的文字可以不同(QTensor 与 QMatMul)。

七处里五处只在 Metal 上出现,而且都是「CPU 报错或正确、Metal 静默」。第 1 节的枚举分派保证了两个后端走同一组检查(同设备、同 dtype),但检查之后的部分,比如步长是否合法、缓冲区是否真的复制、种子怎么写进内核,每个后端各写一遍,没有共享的检查。

8. 实测:同一组小负载,candle 对 burn

实验 crate 是 exp-candle/design-bench(candle 0.11.0,features metal,accel 变体再开 accelerate)和 exp-candle/design-burn(burn 0.22.1,features flex、metal,加上默认 feature,所以 Metal 上带着 fusion 和 autotune;没开 cpu、autotune-checks)。两边的程序逐行对应,都是 release。CPU 一侧 candle 跑两种:开 accelerate(matmul 走 Accelerate 的 sgemm)和不开(走 gemm crate,burn 的 Flex 也用 gemm)。

  • matmul:两个 1024×1024 的 f32 随机矩阵,先同步;计时第一次 matmul 到同步完成,再跑 20 次、每次同步,取最短和中位数。
  • MLP 训练步:4 → 16 → 1,ReLU,MSE,Adam(candle 用 AdamW 且 weight_decay = 0),学习率 1e-2,64 个样本,输入由同一个线性同余发生器生成。第一步单独计时,再跑 20 步预热,然后两种模式各 200 步:「读回」每步 to_scalar / into_scalar 读 loss,逐步计时取中位数;「不读回」整个循环只在最后同步一次,给出平均每步,括号里是同步之前循环本身的时间。
  • 逐元素链:沿用 burn 卷 Fusion 装饰器 的设定,Metal,4096² f32,每轮对 5 组输入各算一次、保留结果、同步一次,7 轮取中位数。
  • 小 op:16 个 f32 的加法,CPU,循环 100 万次。

candle 的 MLP 训练步(burn 一侧是同样的结构,用 GradientsParams::from_grads 和 optim.step(lr, model, grads)):

let mut step = |read: bool| -> anyhow::Result<f32> {
  let loss = loss::mse(&model.forward(&x)?, &y)?;
  let grads = loss.backward()?;
  opt.step(&grads)?;
  Ok(if read { loss.to_scalar::<f32>()? } else { f32::NAN })
};
❗IMPORTANT
动脑筋:同一个 MLP 训练步,每步读回 loss,Metal 上 candle 和 burn 谁快?快多少?提示:张量只有 64×16 这么大,GPU 上的计算几乎不花时间;candle 每个 op 一个内核、立即编码,burn 先登记、再融合。先猜,再看文末。

测量时机器上还有别的写手在编译和运行程序,uptime 的负载在 5 到 60 之间。下面先是负载高时的第一组:burn 的第一个进程里 target/environment/ 还不存在(空的 autotune 缓存),后两个进程缓存已在:

--- run 1
burn flex 1024x1024 f32: first 6.582 ms  later min 3.764 ms median 3.945 ms (544 GFLOP/s)
burn metal device init + first op: 37.462 ms
burn metal 1024x1024 f32: first 4242.674 ms  later min 1.817 ms median 2.161 ms (994 GFLOP/s)
--- run 2
burn flex 1024x1024 f32: first 4.990 ms  later min 3.460 ms median 3.988 ms (539 GFLOP/s)
burn metal device init + first op: 12.453 ms
burn metal 1024x1024 f32: first 85.245 ms  later min 1.284 ms median 1.648 ms (1303 GFLOP/s)
...

负载降到 6 左右时三个进程依次跑完 candle 和 burn(burn 的缓存已在):

--- run 1
candle cpu(accelerate) 1024x1024 f32: first 1.385 ms  later min 0.841 ms median 0.875 ms (2453 GFLOP/s)
candle new_metal: 9.095 ms
candle metal 1024x1024 f32: first 3.701 ms  later min 1.133 ms median 1.246 ms (1724 GFLOP/s)
candle cpu(gemm) 1024x1024 f32: first 3.835 ms  later min 3.133 ms median 3.272 ms (656 GFLOP/s)
burn flex 1024x1024 f32: first 4.396 ms  later min 3.425 ms median 3.619 ms (593 GFLOP/s)
burn metal device init + first op: 12.244 ms
burn metal 1024x1024 f32: first 86.797 ms  later min 0.729 ms median 1.300 ms (1652 GFLOP/s)
--- run 2
...
candle metal 1024x1024 f32: first 3.703 ms  later min 0.520 ms median 1.150 ms (1868 GFLOP/s)
...
burn metal 1024x1024 f32: first 81.616 ms  later min 0.686 ms median 1.710 ms (1256 GFLOP/s)
--- run 3
...
candle metal 1024x1024 f32: first 3.733 ms  later min 1.111 ms median 1.384 ms (1551 GFLOP/s)
...
burn metal 1024x1024 f32: first 80.765 ms  later min 1.670 ms median 1.849 ms (1161 GFLOP/s)
--- run 1
candle cpu(accelerate): first step 120.8 us, read: median 35.8 us min 28.0 us, noread: 31.0 us/step (loop 31.0), loss after 221 steps 0.008543
candle metal: first step 19247.7 us, read: median 1508.4 us min 1421.7 us, noread: 776.9 us/step (loop 720.0), loss after 221 steps 0.017277
candle cpu(gemm): first step 134.5 us, read: median 34.8 us min 32.0 us, noread: 33.2 us/step (loop 33.2), loss after 221 steps 0.013487
burn flex: first step 369.5 us, read: median 35.9 us min 33.0 us, noread: 36.0 us/step (loop 36.0), loss after 221 steps 0.010638
burn metal: first step 174437.6 us, read: median 671.6 us min 565.7 us, noread: 562.1 us/step (loop 541.1), loss after 221 steps 0.009722
--- run 2
...
candle metal: first step 18744.9 us, read: median 1359.8 us min 1129.1 us, noread: 782.4 us/step (loop 697.4), loss after 221 steps 0.017277
...
burn metal: first step 181454.8 us, read: median 766.5 us min 643.0 us, noread: 505.3 us/step (loop 484.3), loss after 221 steps 0.006926
--- run 3
...
candle metal: first step 19501.4 us, read: median 1532.2 us min 1217.2 us, noread: 790.3 us/step (loop 704.9), loss after 221 steps 0.017277
...
burn metal: first step 186300.9 us, read: median 735.9 us min 620.5 us, noread: 556.2 us/step (loop 538.2), loss after 221 steps 0.014671

负载高时那一轮里 burn 第一个进程的 MLP(matmul 的调优键已在缓存里,MLP 的形状是新键):

burn metal: first step 4552777.1 us, read: median 699.0 us min 598.8 us, noread: 816.9 us/step, loss after 221 steps 0.006491

逐元素链(第 1 个进程的 candle expr 第一次是 593 ms,后两个进程是 8 ms 左右,这里列第 3 个进程):

candle chain8 4096x4096 f32: first evaluation 7.212458ms
candle chain8: per evaluation min 3.203483ms median 3.208233ms (of 7 rounds)
candle expr 4096x4096 f32: first evaluation 8.261791ms
candle expr: per evaluation min 1.773816ms median 1.873766ms (of 7 rounds)
burn chain8 4096x4096 f32: first evaluation 14.567167ms
burn chain8: per evaluation min 454.758µs median 515.966µs (of 7 rounds)
burn expr 4096x4096 f32: first evaluation 22.048125ms
burn expr: per evaluation min 782.358µs median 795.308µs (of 7 rounds)

小 op(每个程序跑 3 轮,各跑 3 个进程,这里各列一个进程):

candle cpu add 16xf32 round 0: 125.5 ns/op
candle cpu add 16xf32 round 1: 124.2 ns/op
candle cpu add 16xf32 round 2: 125.9 ns/op
burn flex add 16xf32 round 0: 238.8 ns/op
burn flex add 16xf32 round 1: 239.1 ns/op
burn flex add 16xf32 round 2: 238.5 ns/op

汇总(低负载的三个进程,范围是三次的中位数;第一次一栏是新进程里的第一次,系统的 Metal 库缓存和 burn 的 autotune 缓存都已就绪):

负载设备candleburn说明
1024² matmul,之后每次CPU0.87–0.91 ms(Accelerate);3.26–4.15 ms(gemm)3.62–3.84 ms(Flex,gemm)不开 accelerate 时两边同一个 gemm crate,打平
1024² matmul,之后每次Metal1.15–1.38 ms1.30–1.85 msMLX 的固定分块对 autotune 挑出的内核,这个尺寸上不输
1024² matmul,进程里第一次Metal3.70–3.73 ms81–87 msburn 每个进程要编译 MSL;空 autotune 缓存时 4.24 s
MLP 一步,读回 lossCPU34.7–35.8 µs34.4–35.9 µs打平
MLP 一步,读回 lossMetal1.36–1.53 ms0.67–0.77 msburn 快约一倍
MLP 一步,不读回(循环本身)Metal777–790 µs(697–720)505–562 µs(484–541)两边都是 CPU 一侧的开销
MLP 进程里第一步Metal18.7–19.5 ms174–186 ms空 autotune 缓存时 4.55 s
8 个逐元素 opMetal3.21 ms0.52 msburn 融合成一个内核
(a * b + c).exp()Metal1.87–1.90 ms0.80–0.90 ms同上
16 元素加法CPU122–128 ns237–239 nsburn 多了 Dispatch 和 Tensor 两层
  • CPU 上两边的训练步一样快,矩阵乘在不开 accelerate 时也一样快:同一个 gemm crate 做主体,框架的层数只在 16 个元素这种尺度上看得出来。candle 的优势来自 accelerate 这个 feature,不来自框架本身。
  • Metal 上,大的单个内核(1024² matmul)两边相近;op 多、张量小的负载 burn 快,融合把多个 op 并成一个内核,逐元素链快 2 到 6 倍。MLP 训练步上 burn 快约一倍,第 2 节的采样说明了 candle 的时间去了哪:梯度累加先建零(一次 blit)、参数写回(又一次 blit),每次都要切换编码器。
  • 启动成本反过来:热缓存的新进程里,candle 第一次 matmul 是 burn 的二十分之一,MLP 第一步是十分之一。冷的时候两边都是秒级,原因不同:candle 是 macOS 编译整个 .metal 文件,burn 是 autotune 测设备、比候选。
  • 这些数只对这台机器、这几个尺寸有效;负载高时 Metal 的中位数会漂 20% 到 40%。

总表

取舍换来的付出的证据
1. 后端是枚举层少(16 元素加法 125 ns 对 burn 238 ns);开 metal 二进制只多 0.85 MB后端集合封闭;自动微分、融合不能套上去;没开的后端运行期才知道Storage 与后端 trait、candle 解决什么问题、本页实验
2. 即时执行、不融合错误和计时都在调用处;手写融合算子在 Metal 上快 4–16 倍其余一个 op 一个内核:8 个逐元素 op 3.21 ms 对 burn 0.52 ms;融合算子没有反向常用层、自定义算子、本页实验
3. Result 到处都是形状、dtype、设备错误是值;库内部只用 ?处处 ?;默认没有调用位置,打开每个错误 11 µs;仍有 8 处 panic、1 处死锁错误、怎么读这一卷
4. Arc<RwLock<Storage>>clone 4 ns、视图 50 ns;Var::set 原地更新,模型不重建inplace_op2 自锁;写穿视图;copy 复制整块,Metal 上不复制Tensor 的结构、优化器与训练循环
5. 手写内核、运行期编译不维护编译器;热启动第一次 matmul 3.7 ms(burn 81–87 ms)冷启动按库付,gemm 4.0 s、reduce 4.5 s,取决于系统缓存;逐后端手写、覆盖不齐;不调优Metal 后端、生成一个 token、本页实验
6. 为推理而生GGUF、量化、KV 缓存、取样都在库里;Metal decode 2.5 ms/token优化器只有 SGD 和 AdamW,没有调度和裁剪;融合算子、QTensor 不求导;CPU 不能设种子06 量化、优化器与训练循环
7. 正确性记录—7 处静默算错,5 处只在 Metal 上本页第 7 节
8. 同机实测CPU 上与 burn 打平;Metal 大 matmul 相近;启动快一个数量级以上Metal 上小张量、多 op 的负载 burn 快 2 到 6 倍本页实验

动脑筋答案

burn 快,约一倍:每步读回 loss 时,candle 的中位数是 1.36–1.53 ms,burn 是 0.67–0.77 ms。

candle metal: first step 19247.7 us, read: median 1508.4 us min 1421.7 us, noread: 776.9 us/step (loop 720.0), loss after 221 steps 0.017277
burn metal: first step 174437.6 us, read: median 671.6 us min 565.7 us, noread: 562.1 us/step (loop 541.1), loss after 221 steps 0.009722
  • 两边的时间都主要在 CPU 一侧:不读回时,candle 每步 777 µs 里有 720 µs 是循环本身,burn 562 µs 里有 541 µs。GPU 上 64×16 的张量几乎不花时间。
  • candle 每步的 CPU 时间按阶段拆开(PHASES=1 模式,200 步平均,同一次运行的 CPU 一行做对照):
candle phases Cpu: forward+loss 3.2 us, backward 18.1 us, AdamW step 11.4 us (CPU time per step, 200 steps)
candle phases Metal { gpu_id: 4294968354 }: forward+loss 51.0 us, backward 437.4 us, AdamW step 239.6 us (CPU time per step, 200 steps)

反向占了六成,原因见第 2 节的采样:GradStore::or_insert 先 zeros_like,Metal 上是一次 blit 填零,加上编码器切换。CPU 上同样的 zeros_like 只是一次 vec![0f32; n]。

  • candle 读回比不读回每步多 0.6–0.7 ms:to_scalar 先 blit 再提交、等待,固定成本是一次往返(一次 matmul)。burn 读回和不读回差 0.1–0.2 ms;它为什么少付这一笔,本页没有拆开。
  • CPU 上两边是 34–36 µs,一样快;Metal 上两边都比 CPU 慢 20 到 45 倍。这么小的模型,在哪个框架上都该放在 CPU 上训练。

小结

  • 枚举后端让 candle 层少、二进制小,代价是后端集合封闭,自动微分写死在 Tensor 里,融合只能逐个算子手写。
  • 即时执行让错误和计时都在调用处;没手写融合的地方一个 op 一个内核,Metal 上逐元素链比 burn 慢 2 到 6 倍,融合算子也没有反向。
  • 几乎处处 Result,但不是全部:本卷碰到 8 种 panic 和一种死锁;默认没有调用位置。
  • Arc<RwLock<Storage>> 让 clone 和视图几乎免费、参数原地更新;代价是写穿视图、inplace_op2 自锁、copy 的语义两个后端不同。
  • 手写内核、运行期整文件编译:热启动比 burn 快一个数量级以上,冷启动按库付秒级,取决于 macOS 的缓存;没有 autotune。
  • 推理需要的都在库里,训练只有 SGD 和 AdamW;同机实测 CPU 上两边打平,Metal 上小张量的训练步 burn 快约一倍。
  • 本卷复现了 7 处静默算错,5 处只在 Metal 上:检查在 Storage 层共享,检查之后的部分每个后端各写一遍。

💬 关联讨论

发起讨论
加载中...