Rust in Everything
基于 candle 0.11.0

什么时候选 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.dylib402.3 MiB
transformers55.0 MiB
其中 transformers/models(540 个模型目录)99.0 MiB(含字节码缓存)
sympy / numpy / tokenizers28.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 都编译进去:

构建文件大小动态链接
release7.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,CPU2.10 s2.14 simport torch 488.5 ms,import transformers 590.6 ms,加载 702.1 ms,生成 33.8 ms
candle,CPU0.13 s0.14 s加载 101.1 ms,到第一个 token 130.3 ms
candle,Metal(着色器缓存是热的)0.15 s0.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,CPU2.39 s2.57 s
candle,CPU0.41 s0.41 s
candle,Metal0.22 s0.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,CPU1121.2 – 1122.8 MiB
candle,CPU860.8 – 865.6 MiB
candle,Metal556.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 内存。

❗IMPORTANT
动脑筋:candle CPU 只加载模型、不生成,峰值 RSS 是 860.1 MiB。如果把 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 这四步。

📝NOTE
冷知识:candle 的 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,每次改动要重新编译。

💬 关联讨论

发起讨论
加载中...