什么时候选 candle
主线位置:推理主线的最后一步,量化与部署。本页只比较交付出去的东西:同一个 SmolLM2 贪心生成,两边要装多大的环境、到第一个 token 要多久、峰值占多少内存;再列出 candle 不合适的情况。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB task["SmolLM2 生成"] --> pyside["Python 加 torch"] task --> rsside["candle 二进制"] pyside --> pysize["环境约 788 MiB"] pyside --> pystart["首 token 2.10 s"] rsside --> rssize["约 5.8 MiB"] rsside --> rsstart["首 token 0.13 s"] pyside --> weights["同一份权重 257 MiB"] rsside --> weights rsside --> cost["训练 算子 编译"]
这一页只写在本机测得到的东西。平台 M2 Max、macOS 27、rustc 1.99.0。计时时机器上还有别的任务(uptime 负载 3.7 到 4.1),每项跑 10 次,取最小值,同时给出中位数。文件都已在页缓存里,冷的只是进程。
同一个生成任务
任务是 SmolLM2-135M-Instruct(f32)对 The capital of France is 贪心生成,第一个参数是生成多少个 token。transformers 一侧,进程里第一行代码就开始计时,各段时间写到 stderr;PyTorch 线程数固定为 8,只用 CPU:
import time
T0 = time.perf_counter()
import sys
import torch
T_TORCH = time.perf_counter()
from transformers import AutoModelForCausalLM, AutoTokenizer
T_TF = time.perf_counter()
model_dir = sys.argv[1] # 本地的 SmolLM2-135M-Instruct 权重
steps = int(sys.argv[2])
torch.set_num_threads(8)
tok = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForCausalLM.from_pretrained(
model_dir, dtype=torch.float32, attn_implementation="eager"
).eval()
T_LOAD = time.perf_counter()
ids = tok("The capital of France is", return_tensors="pt").input_ids
with torch.inference_mode():
out = model.generate(ids, max_new_tokens=steps, do_sample=False)
T_GEN = time.perf_counter()
print(repr(tok.decode(out[0, ids.shape[1]:].tolist())))
# ... 把 T0 到 T_GEN 的各段写到 stderr
candle 一侧,分词用 Rust 的 tokenizers crate 读同一个 tokenizer.json,模型用 candle_transformers::models::llama:
fn main() -> Result<()> {
let t0 = Instant::now();
let args: Vec<String> = std::env::args().collect();
let dev = match args.get(1).map(String::as_str) {
Some("metal") => Device::new_metal(0)?,
_ => Device::Cpu,
};
let model_dir = args.get(2).context("model dir")?; // 例如 "SmolLM2-135M-Instruct"
let steps: usize = args.get(3).context("steps")?.parse()?;
let tok = Tokenizer::from_file(format!("{model_dir}/tokenizer.json")).map_err(|e| anyhow!(e))?;
let cfg: LlamaConfig = serde_json::from_slice(&std::fs::read(format!("{model_dir}/config.json"))?)?;
let cfg = cfg.into_config(false);
// ...
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[format!("{model_dir}/model.safetensors")], dtype, &dev)?
};
let model = Llama::load(vb, &cfg)?;
let mut cache = Cache::new(true, DType::F32, &cfg, &dev)?;
let t_load = t0.elapsed();
// ...
let ids = tok.encode("The capital of France is", false).map_err(|e| anyhow!(e))?;
let ids = ids.get_ids();
let input = Tensor::new(ids, &dev)?.unsqueeze(0)?;
let logits = model.forward(&input, 0, &mut cache)?.squeeze(0)?;
let mut next = logits.argmax(0)?.to_scalar::<u32>()?;
let t_first = t0.elapsed();
let mut out = vec![next];
let mut pos = ids.len();
for _ in 1..steps {
let x = Tensor::new(&[next], &dev)?.unsqueeze(0)?;
let l = model.forward(&x, pos, &mut cache)?.squeeze(0)?;
next = l.argmax(0)?.to_scalar::<u32>()?;
pos += 1;
out.push(next);
}
println!("{:?}", tok.decode(&out, false).map_err(|e| anyhow!(e))?);
// ... 把 t_load、t_first 和总时间写到 stderr
Ok(())
}
dtype 默认是 DType::F32,被 // ... 省掉的那一行只给下面的内存实验用。依赖和怎么读这一卷的一样,另加 tokenizers = { version = "*", default-features = false, features = ["onig"] }。
生成 20 个 token,两边的输出:
' Paris. Paris is the largest city in France and the capital of the French department of the Espace'
" Paris. Paris is the largest city in France and the capital of the French department of the Espace"
20 个 token 逐个相同。logits 两边差多少,见怎么读这一卷:最大 |ref| 20.6,最大差 2.98e-5。
要交付多大的东西
两边都要带上同一份权重:model.safetensors 269 060 552 字节(256.6 MiB,bf16),加 2.1 MB 的 tokenizer.json 和 861 字节的 config.json。差别在权重以外的部分。
PyTorch 一侧:Python 解释器,加 torch 和 transformers 按本机平台解析出来的全部依赖(不含 extras),一共 34 个分发包。按 du -sk -I __pycache__ 量(不算运行之后才生成的字节码缓存),1 MiB = 1024 KiB:
| 部分 | 大小 |
|---|---|
| Python 3.13.18 解释器(uv 安装的独立构建) | 65.6 MiB |
torch(含 functorch、torchgen) | 567.7 MiB |
其中 torch/lib/libtorch_cpu.dylib | 402.3 MiB |
transformers | 55.0 MiB |
其中 transformers/models(540 个模型目录) | 99.0 MiB(含字节码缓存) |
sympy / numpy / tokenizers | 28.5 / 21.6 / 9.4 MiB |
其余 29 个包(huggingface_hub、hf_xet、networkx、safetensors、regex、rich……) | 39.9 MiB |
| 合计 | 787.7 MiB |
跑过一次之后,各包的 __pycache__ 又占 200 多 MiB:同样这些目录,带字节码缓存量是 1005.8 MiB。torch 和 transformers 各自声明的依赖不重叠的部分也不小:sympy 是 torch 声明的依赖,28.5 MiB;numpy 是 transformers 声明的依赖,21.6 MiB。这是 macOS 上只带 CPU 的 wheel;Linux 上 torch 还会拉一批 CUDA 包,本页没有装,大小没有量(burn 卷的为什么要转列了 wheel 元数据里的那几行)。
candle 一侧是一个可执行文件,release 构建,metal、accelerate、tokenizers 都编译进去:
| 构建 | 文件大小 | 动态链接 |
|---|---|---|
| release | 7.3 MiB(7 620 416 字节) | — |
release,strip 之后 | 5.8 MiB(6 034 304 字节) | libSystem、libiconv、libobjc,以及 Metal、Accelerate、Foundation、CoreFoundation 四个系统框架 |
strip 之后的二进制照样跑出同样的 20 个 token。787.7 MiB 对 5.8 MiB,差约 137 倍,加上 257 MiB 的权重和 2.1 MB 的 tokenizer.json 之后,交付物是约 1046 MiB 对约 264 MiB。一个只做 matmul、只开 CPU 的 candle 程序能小到 1 MiB 以下,见源码卷的 candle 解决什么问题。
到第一个 token 要多久
参数传 1,只生成第一个 token 就退出;/usr/bin/time -l 量整个进程的墙钟时间(分辨率 10 ms),10 次:
| 程序 | 最小 | 中位数 | 进程内各段(最后一次) |
|---|---|---|---|
| transformers,CPU | 2.10 s | 2.14 s | import torch 488.5 ms,import transformers 590.6 ms,加载 702.1 ms,生成 33.8 ms |
| candle,CPU | 0.13 s | 0.14 s | 加载 101.1 ms,到第一个 token 130.3 ms |
| candle,Metal(着色器缓存是热的) | 0.15 s | 0.16 s | 加载 109.9 ms,到第一个 token 147.9 ms |
transformers 一侧,进程内从第一行到生成结束合计约 1.8 秒,另外约 0.3 秒花在解释器启动和退出上。两次 import 加起来 1.08 秒,比加载权重还久,而且和模型无关。candle 一侧没有 import:映射文件、把 134 515 008 个 bf16 参数转成 f32 放进内存,一共 101 ms,prefill 5 个 token 约 30 ms。
Metal 那一行要说明:这个二进制在 Metal 上第一次运行时,到第一个 token 用了 9.92 秒(加载 4141.6 ms,到第一个 token 9924.9 ms),时间花在编译 .metal 着色器库上;macOS 把编译结果缓存下来,之后每次 0.15 秒左右。这个冷启动由系统缓存决定,见源码卷的 Metal 首次运行很慢。
生成 20 个 token 的整个进程:
| 程序 | 最小 | 中位数 |
|---|---|---|
| transformers,CPU | 2.39 s | 2.57 s |
| candle,CPU | 0.41 s | 0.41 s |
| candle,Metal | 0.22 s | 0.23 s |
后 19 个 token 在两边的 CPU 上都是每个 15 ms 上下(transformers 一次运行里生成 20 个 token 337.4 ms,candle 第一个 token 之后 273.9 ms),差别几乎全在启动。稳态的 tokens/s 怎么比,见速度。
峰值内存
同样 10 次,/usr/bin/time -l 报的 maximum resident set size:
| 程序 | 峰值 RSS |
|---|---|
| transformers,CPU | 1121.2 – 1122.8 MiB |
| candle,CPU | 860.8 – 865.6 MiB |
| candle,Metal | 556.1 – 558.0 MiB |
这和 burn 卷那种「几 MiB 对几百 MiB」的差距不是一回事:内存的大头是模型。f32 的权重本身是 134 515 008 × 4 字节 = 513.1 MiB;from_mmaped_safetensors 映射的 bf16 文件在转换时被读进内存,又是 256.6 MiB 左右。把 Python 一侧拆开量:
| 进程 | 峰值 RSS |
|---|---|
python -c "pass" | 15.6 MiB |
python -c "import torch" | 205.8 MiB |
python -c "import torch, transformers; from transformers import AutoModelForCausalLM" | 231.6 MiB |
| candle,CPU,只加载模型不生成 | 860.1 MiB |
两边的差(约 260 MiB)和 import 的开销(231.6 − 15.6 = 216 MiB)是一个量级:两边都载入同一份 f32 权重,transformers 一侧多出来的主要是 Python 运行时和那几个库。
Metal 一行不能和 CPU 行直接比:权重在 Metal 缓冲区里,time -l 的 RSS 只有 556 MiB,比 f32 权重加映射文件的 770 MiB 还少,可见 GPU 一侧的内存没有全部算进这个数。本页没有另外量 GPU 内存。
DType::F32 改成 DType::BF16,按文件里的 dtype 直接加载,峰值 RSS 是多少?文件是 256.6 MiB 的 bf16。先猜一个数,再看文末。什么时候不该选 candle
训练很薄
candle 能训练,但工具很少:candle-nn/src/optim.rs 一共 201 行,只有不带动量的 SGD(源码注释原文「Contrary to the PyTorch implementation of SGD, this version does not support momentum.」)和 AdamW,candle-nn 里没有学习率调度,没有梯度裁剪。推理用的融合算子没有反向,没有 torch.no_grad。这些在自动求导与训练一部分逐个对照,取舍的全貌见源码卷的设计取舍第 6 节。如果主要工作是训练和试验模型,PyTorch 更合适。
算子覆盖
transformers 5.22.0 的 524 个 modeling_*.py 里,用 .topk( 的有 104 个文件,masked_fill 167 个,nonzero( 96 个,torch.einsum( 62 个(grep -l 计数)。注意力默认走 torch.nn.functional.scaled_dot_product_attention(在 integrations/sdpa_attention.py 里统一调用)。一个小例子:
x = torch.tensor([[0.1, 0.9, 0.3, 0.7], [0.5, 0.2, 0.8, 0.4]])
v, i = torch.topk(x, 2, dim=-1)
print("topk values =", v.tolist(), "indices =", i.tolist())
mask = torch.tensor([[False, True, False, True], [True, False, False, False]])
print("masked_fill =", x.masked_fill(mask, float("-inf")).tolist())
print("nonzero =", mask.nonzero().tolist())
print("einsum =", torch.einsum("i,j->ij", torch.tensor([1.0, 2.0]), torch.tensor([3.0, 4.0, 5.0])).tolist())
q = k = vv = torch.arange(256, dtype=torch.float32).reshape(1, 1, 4, 64) / 256
print("sdpa =", F.scaled_dot_product_attention(q, k, vv, is_causal=True)[0, 0, -1, :4].tolist())
topk values = [[0.8999999761581421, 0.699999988079071], [0.800000011920929, 0.5]] indices = [[1, 3], [2, 0]]
masked_fill = [[0.10000000149011612, -inf, 0.30000001192092896, -inf], [-inf, 0.20000000298023224, 0.800000011920929, 0.4000000059604645]]
nonzero = [[0, 1], [0, 3], [1, 0]]
einsum = [[3.0, 4.0, 5.0], [6.0, 8.0, 10.0]]
sdpa = [0.6980971693992615, 0.7020034790039062, 0.7059096693992615, 0.7098159790039062]
照着写成 candle:
use candle_core::{Device, Tensor};
fn main() -> candle_core::Result<()> {
let dev = Device::Cpu;
let x = Tensor::new(&[[0.1f32, 0.9, 0.3, 0.7], [0.5, 0.2, 0.8, 0.4]], &dev)?;
let mask = Tensor::new(&[[0u8, 1, 0, 1], [1, 0, 0, 0]], &dev)?;
let _top = x.topk(2)?;
let _filled = x.masked_fill(&mask, f32::NEG_INFINITY)?;
let _nz = mask.nonzero()?;
let _outer = candle_core::einsum("i,j->ij", &[&x, &x])?;
Ok(())
}
error[E0425]: cannot find function `einsum` in crate `candle_core`
--> src/bin/missing.rs:10:29
|
10 | let _outer = candle_core::einsum("i,j->ij", &[&x, &x])?;
| ^^^^^^ not found in `candle_core`
error[E0599]: no method named `topk` found for struct `candle_core::Tensor` in the current scope
--> src/bin/missing.rs:7:16
|
7 | let _top = x.topk(2)?;
| ^^^^ method not found in `candle_core::Tensor`
|
...
109 | fn topk(&self, topk: usize) -> Result<TopKOutput>;
| ---- the method is available for `candle_core::Tensor` here
|
= help: items from traits can only be used if the trait is in scope
help: trait `TopKLastDimOp` which provides `topk` is implemented but not in scope; perhaps you want to import it
|
1 + use candle_transformers::models::deepseek2::TopKLastDimOp;
|
error[E0599]: no method named `masked_fill` found for struct `candle_core::Tensor` in the current scope
--> src/bin/missing.rs:8:19
|
8 | let _filled = x.masked_fill(&mask, f32::NEG_INFINITY)?;
| ^^^^^^^^^^^ method not found in `candle_core::Tensor`
error[E0599]: no method named `nonzero` found for struct `candle_core::Tensor` in the current scope
--> src/bin/missing.rs:9:18
|
9 | let _nz = mask.nonzero()?;
| ^^^^^^^ method not found in `candle_core::Tensor`
|
...
help: trait `NonZeroOp` which provides `nonzero` is implemented but not in scope; perhaps you want to import it
|
1 + use candle_transformers::models::deepseek2::NonZeroOp;
|
四个都不在 candle-core 里。编译器给 topk 和 nonzero 指了一条路:TopKLastDimOp 和 NonZeroOp 是 candle-transformers 里 DeepSeek V2 模型文件自己定义的 trait,因为这个 crate 依赖了 candle-transformers,编译器才找得到。topk 是「降序 argsort,取前 k 个,再 gather」;nonzero 是一个只有 CPU 实现的自定义算子,在别的设备上先拷回 CPU。masked_fill 则是每个模型文件各写一份:candle-transformers 0.11.0 里有 23 个文件定义了自己的 fn masked_fill,比如 llama.rs 里的这个。
这几个都能用已有的算子拼出来,结果和 PyTorch 一样:
// topk = 降序 argsort + narrow + gather
let idx = x.arg_sort_last_dim(false)?.narrow(D::Minus1, 0, 2)?.contiguous()?;
println!("topk values = {:?} indices = {:?}", x.gather(&idx, D::Minus1)?.to_vec2::<f32>()?, idx.to_vec2::<u32>()?);
// masked_fill = where_cond,参数顺序:mask 为真取第一个
let mask = Tensor::new(&[[0u8, 1, 0, 1], [1, 0, 0, 0]], &dev)?;
let neg = Tensor::new(f32::NEG_INFINITY, &dev)?.broadcast_as(x.shape())?;
println!("masked_fill = {:?}", mask.where_cond(&neg, &x)?.to_vec2::<f32>()?);
// einsum("i,j->ij") = 外积
let a = Tensor::new(&[1f32, 2.0], &dev)?;
let b = Tensor::new(&[3f32, 4.0, 5.0], &dev)?;
println!("einsum = {:?}", a.unsqueeze(1)?.broadcast_mul(&b.unsqueeze(0)?)?.to_vec2::<f32>()?);
topk values = [[0.9, 0.7], [0.8, 0.5]] indices = [[1, 3], [2, 0]]
masked_fill = [[0.1, -inf, 0.3, -inf], [-inf, 0.2, 0.8, 0.4]]
einsum = [[3.0, 4.0, 5.0], [6.0, 8.0, 10.0]]
用到的是 Tensor::arg_sort_last_dim、Tensor::gather 和 Tensor::where_cond。arg_sort_last_dim 只排最后一维,PyTorch 的 topk(dim=...) 对别的维度要先 transpose。argsort 的索引是 U32,PyTorch 是 int64(归约与索引)。nonzero 用已有的算子拼不出来:输出的形状取决于数据。DeepSeek V2 的做法是写一个自定义算子,在 cpu_fwd 里数出非零元素再返回形状。
sdpa 是另一种情况:candle-nn 有 candle_nn::ops::sdpa,但只在 Metal 上能用。同一个输入,CPU 和 Metal 各调一次:
let q = (Tensor::arange(0f32, 256.0, &dev)?.reshape((1, 1, 4, 64))? / 256.0)?;
match candle_nn::ops::sdpa(&q, &q, &q, None, true, 0.125, 1.0) {
Ok(t) => println!("sdpa cpu = {:?}", t.to_vec3::<f32>()),
Err(e) => println!("sdpa cpu: Err({e})"),
}
let out = attention(&q, &q, &q)?; // 手拼:matmul、tril2 掩码、softmax_last_dim、matmul
println!("attention = {:?}", out.squeeze(0)?.squeeze(0)?.get(3)?.narrow(0, 0, 4)?.to_vec1::<f32>()?);
let mdev = Device::new_metal(0)?;
let qm = q.to_device(&mdev)?;
let m = candle_nn::ops::sdpa(&qm, &qm, &qm, None, true, 0.125, 1.0)?;
println!("sdpa metal = {:?}", m.squeeze(0)?.squeeze(0)?.get(3)?.narrow(0, 0, 4)?.to_vec1::<f32>()?);
sdpa cpu: Err(SDPA has no cpu impl)
attention = [0.69809717, 0.7020034, 0.70590967, 0.7098159]
sdpa metal = [0.69809717, 0.7020035, 0.70590967, 0.709816]
CPU 上是一个 Err,原因写在 Sdpa 的 cpu_fwd 里:函数体只有一句 candle::bail!("SDPA has no cpu impl")。源码卷的常用层把它和 rms_norm、layer_norm 这些融合算子放在一起讲过。手拼的注意力和 Metal 的 sdpa 和 PyTorch 的 4×64 个输出(最大 |ref| 0.944)比,最大差都是 5.96e-8(容差 1e-6)。Metal 版本对形状也有要求:最先用 4×4 的输入试,报的是 Meta SDPA does not support q head dim 4: q dims [1, 1, 4, 4], k dims [1, 1, 4, 4], v dims [1, 1, 4, 4].,换成 head dim 64 才跑通。Sdpa::metal_fwd 只接受 32、64、72、80、96、128、256、512 这几种 head dim。要在 CPU 上跑,注意力只能自己拼;candle_transformers::models::llama 不开 flash-attn 时就是 matmul、masked_fill、softmax_last_dim、matmul 这四步。
sdpa 从一开始就是 Metal 专用的candle_nn::ops::sdpa 和 SDPA has no cpu impl 这一句是同一个提交加进来的:#2584「Add some fast Metal MLX SDPA kernels」(2024-11-05,首次发布于 0.8.0)。这个提交加的是 MLX 风格的 Metal 内核,提交说明里列了「Add full sdpa kernel」「Add vectorized kernel for decoding」「Add softcapping for full sdpa」和「Add support for head dim 32, 96, 256」,CPU 一侧只放了一个返回错误的 cpu_fwd。所以它不是 PyTorch scaled_dot_product_attention 那样的「哪个后端都能调、内部选实现」的入口,而是一个 Metal 加速内核;上面 head dim 4 被拒,是 metal_fwd 里那张 head dim 白名单在起作用。
还有一类不在这个例子里的差别:PyTorch 算子大多能接受任意维度和 dtype,candle 的融合算子常常要求连续内存、只支持最后一维(softmax_last_dim、arg_sort_last_dim),整数和浮点之间不做类型提升。这些在 01 张量一部分逐条对照。burn 卷的为什么要转对 burn 做过同样的检查,缺的是 unique、searchsorted、bincount、linalg.inv。
没有编译这一层,但有编译这一步
- 没有
torch.compile。 candle 每个 op 立即执行,没有图捕获,也没有通用的融合器;要融合就像sdpa、rms_norm那样手写一个自定义算子,每个设备写一份内核(candle 解决什么问题、自定义算子)。PyTorch 那边写一行torch.compile(model)就能试的优化,在 candle 里没有对应物。 - 改一行代码要重新编译。 本页的生成程序第一次 release 构建要编译 327 个 crate,用了 77.9 秒(当时机器负载从 2.4 升到 34.9);之后改动
main.rs再构建,release 2.6 秒左右,debug 0.5 到 1.2 秒,各测 3 次。PyTorch 每跑一次脚本先付 1.08 秒的两次 import,但不需要构建,也可以在 REPL 或 notebook 里留着已经加载好的模型一行一行试;candle 每次改动都是重新编译、从头加载。
和 burn 比
burn 和 candle 都是 Rust 框架,侧重点正好错开。burn 0.22.1 的训练栈完整得多:burn-optim 里有十来种优化器,加学习率调度、梯度裁剪,训练循环和指标在 burn-train;它用 Fusion 装饰器在运行期把一串 op 编成融合内核。candle 的长处在推理:HuggingFace 的模型实现(candle-transformers 里 124 个模型模块)、safetensors、GGUF 和量化矩阵乘、KV 缓存、取样都在库里,Metal 上热启动只要几毫秒而 burn 每个新进程要重新生成着色器。要训练、要换后端,看 burn 卷的为什么要转;要把 HuggingFace 上的模型拿来推理,candle 少写很多代码。两者在同一台机器上的实测对比在源码卷的设计取舍。
什么时候选 candle
- 选:推理要交付到不方便装 Python 环境的地方(命令行工具、嵌到 Rust 服务里、按请求起进程),模型在
candle-transformers里已经有,或者结构是 Llama 这类常见的 transformer。本页的数字在这种场合起作用:体积差约 137 倍,到第一个 token 差约 16 倍。 - 不选:以训练和试验为主;模型大量用到
topk、nonzero这类 candle-core 里没有的算子,或者依赖torch.compile、PyTorch 的融合注意力在 CPU 上的性能;要直接复用大量 PyTorch 写的模型代码。内存不是选 candle 的理由:峰值 RSS 的大头是权重,两边差的只是 Python 运行时那 200 多 MiB。
动脑筋答案
candle-cpu load-only bf16: n=5 real min=0.07s median=0.08s peakRSS min=543.2MiB max=544.8MiB
峰值 RSS 543.2 MiB,比 F32 的 860.1 MiB 少 317 MiB,加载时间从约 0.10 秒降到 0.07 秒。
- 543 MiB 约是两份 bf16 的大小(2 × 256.6 = 513 MiB)加上程序其余的部分:一份是映射进来、被读过的文件页,一份是加载后放在 CPU 存储里的权重。所以
from_mmaped_safetensors读完以后并不是直接用映射的内存,权重被复制了一份(源码卷的 safetensors 与 mmap 讲了这一步)。 - 这只是加载。candle 的 CPU 后端不支持 BF16 的 matmul,要用 bf16 跑前向得换到 Metal,见半精度。
- F32 比 BF16 多出的 317 MiB 比两种 dtype 的权重之差(513.1 − 256.6 = 256.5 MiB)还多约 60 MiB,本页没有追查这一部分花在哪。
小结
- 同一个 SmolLM2 贪心生成,两边 20 个 token 相同。
- 交付体积:Python 解释器加 torch、transformers 和 34 个包 787.7 MiB(不含字节码缓存,含则 1005.8 MiB);candle 二进制 strip 后 5.8 MiB,只链接系统库。两边都另带 257 MiB 的权重。
- 到第一个 token(10 次取最小):transformers 2.10 秒,其中两次 import 1.08 秒;candle CPU 0.13 秒,Metal 热缓存 0.15 秒,冷缓存第一次 9.92 秒。后续每个 token 两边的 CPU 上都在 15 ms 上下。
- 峰值 RSS:transformers 1121 MiB,candle CPU 861 MiB;大头是 f32 权重和映射的文件,差距约等于 import 的 216 MiB。Metal 的 RSS 不含全部 GPU 内存。
- 不该选 candle 的情况:训练只有 SGD(无动量)和 AdamW;
topk、masked_fill、nonzero、einsum不在 candle-core 里(前三个模型文件各自定义),融合的sdpa只有 Metal 实现;没有torch.compile,每次改动要重新编译。