Rust in Everything
基于 candle 0.11.0

部署

主线位置:推理主线的最后一步,量化与部署。模型已经能用 GGUF 跑(GGUF),速度也量过了(速度);这一页看交付出去的那个程序:怎么编、链接了什么、权重怎么带、服务长什么样。

版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  src["Rust 源码"] --> prof["release profile"]
  prof --> bin["一个可执行文件"]
  bin --> fw["系统框架"]
  w["Q8_0 GGUF"] --> side["放在旁边 读文件"]
  w --> mm["放在旁边 mmap"]
  w --> emb["include_bytes 编进去"]
  side --> bin
  mm --> bin
  emb --> bin
  bin --> svc["stdin 进 流式出"]

实验代码:candle 侧 crate perf-serve(本页的服务,embed feature 把权重编进去)、perf-prof(和速度同一个基准程序,配了九个 profile)、perf-linux(只依赖 candle-core,用来试别的目标平台);Python 侧 serve.py、startup.py、stream_probe.py。平台 M2 Max、macOS 27、rustc 1.99.0。模型是本地 SmolLM2-135M-Instruct 的 Q8_0 GGUF(144 811 360 字节)和 tokenizer.json(2 104 556 字节),文件都已在页缓存里。

Python 环境有多大(787.7 MiB)、两边到第一个 token 要多久、峰值 RSS 多少,什么时候选 candle 已经量过,这里不重复。burn 卷的部署形态比较了 TorchScript、torch.export、AOTInductor 和 ONNX,那些 PyTorch 侧的形态这里也不再展开:对一个 transformers 的 LLM,它们都还要带上 libtorch。

一个最小服务

先看要交付的东西长什么样。transformers 一侧,最短的写法是一个读 stdin 的循环加 TextStreamer:

tok = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.float32).to(dev).eval()
streamer = TextStreamer(tok, skip_prompt=True, skip_special_tokens=True)  # 每凑成一个词就打印并 flush
for line in sys.stdin:
  if not line.strip():
    continue
  msgs = [{"role": "user", "content": line.strip()}]
  ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True)["input_ids"].to(dev)
  with torch.inference_mode():
    model.generate(ids, max_new_tokens=64, do_sample=False, streamer=streamer)

candle 一侧做同样的事,模型换成 Q8_0 GGUF 和 quantized_llama。transformers 替你做的三件事要自己写:对话模板(分词与对话模板)、在 eos 停(生成循环)、流式输出时不切断多字节字符:

const EOS: u32 = 2; // <|im_end|>
const MAX_NEW: usize = 64;

/// Greedy generation; `emit` gets each newly complete piece of text.
fn generate(model: &mut ModelWeights, tok: &Tokenizer, prompt: &[u32], max_new: usize, dev: &Device, mut emit: impl FnMut(&str) -> Result<()>) -> Result<usize> {
  let mut ids: Vec<u32> = Vec::new();
  let mut printed = 0; // bytes of the decoded answer already emitted
  let mut logits = model.forward(&Tensor::new(prompt, dev)?.unsqueeze(0)?, 0)?.squeeze(0)?;
  while ids.len() < max_new {
    let next = logits.argmax(0)?.to_scalar::<u32>()?;
    if next == EOS {
      break;
    }
    ids.push(next);
    // decode the whole answer and emit only the new suffix, once it no longer ends in a partial character
    let text = tok.decode(&ids, true).map_err(|e| anyhow!(e))?;
    if text.len() > printed && !text.ends_with('\u{fffd}') {
      emit(&text[printed..])?;
      printed = text.len();
    }
    logits = model.forward(&Tensor::new(&[next], dev)?.unsqueeze(0)?, prompt.len() + ids.len() - 1)?.squeeze(0)?;
  }
  Ok(ids.len())
}

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("cpu") => Device::Cpu,
    Some("metal") => Device::new_metal(0)?,
    _ => bail!("usage: perf-serve <cpu|metal> [model_dir]"),
  };
  let (mut model, tok) = load(args.get(2), &dev)?;
  let t_load = t0.elapsed();
  // warm-up: a 2-token prefill and one decode step touch every kernel a request uses
  let warm = tok.encode(chat_prompt("hi").as_str(), false).map_err(|e| anyhow!(e))?.get_ids().to_vec();
  generate(&mut model, &tok, &warm[..2], 2, &dev, |_| Ok(()))?;
  eprintln!("ready: load {:.1} ms, warm-up done at {:.1} ms", t_load.as_secs_f64() * 1e3, t0.elapsed().as_secs_f64() * 1e3);

  let stdout = std::io::stdout();
  for line in std::io::stdin().lock().lines() {
    let line = line?;
    if line.trim().is_empty() {
      continue;
    }
    let t = Instant::now();
    let prompt = tok.encode(chat_prompt(line.trim()).as_str(), false).map_err(|e| anyhow!(e))?.get_ids().to_vec();
    let mut first: Option<f64> = None;
    let mut out = stdout.lock();
    let n = generate(&mut model, &tok, &prompt, MAX_NEW, &dev, |piece| {
      first.get_or_insert(t.elapsed().as_secs_f64() * 1e3);
      out.write_all(piece.as_bytes())?;
      Ok(out.flush()?)
    })?;
    writeln!(out)?;
    out.flush()?;
    eprintln!("request: {} prompt tokens, {n} new tokens, first piece {:.1} ms, total {:.1} ms", prompt.len(), first.unwrap_or(0.0), t.elapsed().as_secs_f64() * 1e3);
  }
  Ok(())
}

chat_prompt 是 07 部分手拼的 SmolLM2 对话模板;load 有两个版本,下面讲权重怎么带时再看。流式输出用的是「每次解码整段回答、只输出新增的后缀」,结尾是 \u{fffd}(半个多字节字符)就先不输出;为什么不能逐 token 解码,见 生成循环。预热那两行来自源码卷的 Metal 第一次运行要好几秒:着色器在第一次用到时编译,放在启动时做,就不会落到第一个请求上。

两个请求,CPU(embed 构建,所以不用传模型目录):

$ printf 'What is the capital of France?\nName three primary colors.\n' | perf-serve cpu
The capital of France is Paris.
The primary colors are:

1. Red: A warm, deep red hue that is often associated with fire, blood, and earth.
2. Blue: A deep, deep blue hue that is often associated with water, sky, and sky.
3. Yellow: A bright, warm yellow hue that is
ready: load 79.2 ms, warm-up done at 92.9 ms
request: 37 prompt tokens, 7 new tokens, first piece 52.8 ms, total 88.4 ms
request: 35 prompt tokens, 64 new tokens, first piece 48.1 ms, total 377.9 ms

第二个请求到了 64 个 token 的上限,句子被截断。确认输出真的是一段一段到的:另一个程序启动服务,等 ready 之后写入一行,记下每次从管道读到数据的时间(Metal,节选):

   10.7 ms  'The'
   13.3 ms  ' primary'
   15.9 ms  ' colors'
   18.4 ms  ' are'
   20.9 ms  ':'
   23.5 ms  '\n\n'
...
  164.0 ms  ' that'
  166.5 ms  ' is'
  167.5 ms  '\n'
65 chunks; stderr: request: 35 prompt tokens, 64 new tokens, first piece 10.7 ms, total 167.5 ms

64 个 token 加最后的换行,读到 65 次,间隔约 2.5 ms。同样的探针对 transformers 的 serve.py(CPU,f32)读到 42 次,第一次在 60.5 ms,结尾在 1002.1 ms;TextStreamer 按词而不是按 token 输出,所以次数少。它从脚本第一行到 ready 用了 1702 到 1713 ms(3 次),峰值 RSS 1120.9 MiB;candle 的服务到 ready 是 0.1 到 0.2 秒,见下面的表。两边回答的文字不同,因为一边是 f32、一边是 Q8_0(GGUF)。

release profile

Cargo.toml 里能调的几项,都是对整个依赖树生效的:

[profile.release]
strip = true          # 去掉符号表
# lto = "fat"         # 跨 crate 的链接时优化
# codegen-units = 1   # 每个 crate 只切一个代码生成单元
# panic = "abort"     # panic 时直接退出,不展开栈
# opt-level = "s"     # 按体积优化

同一个基准程序(metal、accelerate、tokenizers,加 llama 和 quantized_llama 两个模型),每种 profile 在一个空的 target 目录里从头构建(151 个 crate,12 个并行任务),再各跑 5 遍取最小值。构建时机器负载被构建自己推到 5 到 19:

profile构建(s)大小CPU f32 prefill(ms)CPU f32 decode(ms/token)CPU Q8_0 decodeMetal f32 decode
默认 release41.48.05 MiB119.716.407.744.54
strip = true42.16.40 MiB117.816.287.904.59
lto = "thin"38.48.20 MiB116.916.347.864.57
lto = "fat"63.06.49 MiB145.316.357.884.61
codegen-units = 180.46.93 MiB141.316.327.704.62
panic = "abort"38.16.85 MiB136.216.307.814.56
opt-level = 239.28.17 MiB117.716.337.624.62
opt-level = "s"33.79.10 MiB160.416.497.914.55
fat + cgu 1 + abort + strip85.35.04 MiB135.816.557.474.53

(运行时负载 5.8 到 9.0。)

  • decode 不受任何一项影响。 九种构建的 CPU f32 decode 都在 16.28 到 16.55 ms,Metal 4.53 到 4.62 ms。decode 的时间花在 Accelerate 的 sgemm、Metal 内核和线程交接上(速度),这些都不是 rustc 编出来的代码。
  • 体积只有 strip 值得做。 一行配置,构建时间不变,8.05 → 6.40 MiB。四项全开到 5.04 MiB,构建时间翻倍。
  • CPU prefill 反而变慢了。 lto = "fat"、codegen-units = 1、panic = "abort" 都让 prefill 慢 13% 到 21%,opt-level = "s" 慢 34%。重排顺序再跑一轮(默认、fat、abort、cgu1、默认、全开,各 5 遍),结果不变:118.5 / 143.6 / 135.4 / 139.5 / 118.5 / 132.2 ms。对默认和 fat 两个构建的 prefill 各采样 5 秒,where_cond(llama.rs 做因果 mask 用的那一步)在主线程上的占比从 27.5% 变成 40.6%,按各自的总时间折算约 33 ms 对 59 ms,多出的约 26 ms 就是两者的差距。为什么链接时优化让这个函数变慢,本页没有再往下追(反汇编没有看)。
  • 默认 release 已经是 opt-level = 3;opt-level = 2 和它分不出来。

所以对这类程序,release profile 的建议只有一条:加 strip = true。PyTorch 一侧没有对应的旋钮,wheel 是预编译好的。

链接了什么

otool -L 看本页的服务(metal + accelerate):

perf-serve:
	/System/Library/Frameworks/Metal.framework/Versions/A/Metal (compatibility version 1.0.0, current version 382.5.3)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)
	/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 5027.0.69)
	/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 5027.0.69)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
	/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)
	/System/Library/Frameworks/Accelerate.framework/Versions/A/Accelerate (compatibility version 1.0.0, current version 4.0.0)

全部是 macOS 自带的。metal feature 带来 Metal、Foundation、CoreFoundation 和 libobjc(candle 通过 objc2-metal 调 Metal);accelerate feature 带来 Accelerate(accelerate-src 的构建脚本只有一行 cargo:rustc-link-lib=framework=Accelerate)。不开 accelerate 构建,同一个列表里只少 Accelerate 这一行。没有一个要随程序一起分发的动态库,这是它和「Python 加 torch」或「C++ 加 libtorch」最大的不同。

换到 Linux

Metal 和 Accelerate 都是 Apple 的框架,Linux 上一个都没有:在 Linux 上构建要去掉 metal 和 accelerate 两个 feature,CPU 上的矩阵乘回到 gemm crate。这一步在本机上能看到的代价是 速度 里「不开 accelerate」那一行:prefill 慢一倍,decode 慢 18%。candle 给 Linux 准备的两个加速 feature,mkl(Intel MKL)和 cuda,本页没有跑:本机没有 x86 和 NVIDIA 卡,它们依赖的 intel-mkl-src、candle-kernels 也不在本机的 cargo 缓存里,只在 feature 里声明一下,离线解析就会失败:

error: no matching package named `candle-kernels` found
location searched: crates.io index
required by package `candle-core v0.11.0`

没有 Linux 机器,那就试在 macOS 上交叉检查:只依赖 candle-core、一个 feature 都不开,cargo check --target aarch64-unknown-linux-gnu(这个目标的标准库已经装了)。它没走到 candle 自己的代码就停了:

error: failed to run custom build command for `onig_sys v69.9.3`
...
  error occurred in cc-rs: failed to find tool "aarch64-linux-gnu-gcc": No such file or directory (os error 2)

cargo tree -i onig_sys 给出这条依赖链:onig_sys ← onig ← tokenizers v0.22.2 ← candle-core v0.11.0。0.11.0 的 candle-core/Cargo.toml 对所有非 wasm 目标无条件依赖 tokenizers = { workspace = true, features = ["onig"] },onig 要用 C 编译器编 Oniguruma,所以交叉编译要先有目标平台的 C 工具链;把 CC 换成本机的 clang --target=aarch64-linux-gnu 也只是往前走到 fatal error: 'stdlib.h' file not found(没有 Linux 的 sysroot)。在 Linux 机器上原生构建应该没有这个问题,本页没有验证。

📝NOTE
冷知识:candle-core 里的 tokenizers 是为 GGUF 加的

#3245「feat: allow tokenizer to load from GGUF metadata」(首次发布于 0.10.0)新增 candle-core/src/quantized/tokenizer.rs,让程序可以直接从 GGUF 的元数据建分词器,同一个提交在 candle-core/Cargo.toml 里加了 tokenizers = { workspace = true, features = ["onig"] }。从此连一个只做张量运算、不碰分词的程序,也要编译 tokenizers 和 C 写的 Oniguruma,上面的交叉检查就卡在这里。0.11.0 之后,origin/main 上的 #3952「Use fancy-regex feature in tokenizers over onig」把这一行改成了 features = ["fancy-regex"](纯 Rust 的正则),还没有进入任何 release。

wasm 一句:对 wasm32-unknown-unknown 做同样的 cargo check,candle-core 在 wasm 上不依赖 tokenizers,但依赖链里的 getrandom 0.3.4 报「The wasm32-unknown-unknown targets are not supported by default; you may need to enable the "wasm_js" configuration flag」,要另配编译选项;本页没有继续往下做。

权重怎么带

三种带法,load 的区别只在把 GGUF 交给 gguf_file::Content::read 和 ModelWeights::from_gguf 的 reader 从哪来:

// 放在旁边,读文件
let mut r = std::io::BufReader::new(std::fs::File::open(format!("{dir}/SmolLM2-135M-Instruct-Q8_0.gguf"))?);
let content = gguf_file::Content::read(&mut r)?;
let model = ModelWeights::from_gguf(content, &mut r, dev)?;
// 放在旁边,mmap(PERF_MMAP=1)
let map = unsafe { memmap2::Mmap::map(&file)? };
let mut r = std::io::Cursor::new(&map[..]);
let content = gguf_file::Content::read(&mut r)?;
let model = ModelWeights::from_gguf(content, &mut r, dev)?;
// 编进二进制(--features embed)
static GGUF: &[u8] = include_bytes!("SmolLM2-135M-Instruct-Q8_0.gguf");
static TOKENIZER: &[u8] = include_bytes!("tokenizer.json");

let mut r = std::io::Cursor::new(GGUF);
let content = gguf_file::Content::read(&mut r)?;
let model = ModelWeights::from_gguf(content, &mut r, dev)?;
let tok = Tokenizer::from_bytes(TOKENIZER).map_err(|e| anyhow!(e))?;

Content::read 只要 Read + Seek,Cursor<&[u8]> 就满足,所以编进去的字节和映射的文件走同一条路。大小和构建时间(strip = true;增量构建是 touch src/main.rs 后再构建一次):

交付的文件增量构建
读文件 / mmap程序 6 318 064 字节(6.0 MiB)+ GGUF 138.1 MiB + tokenizer.json 2.0 MiB2.4 s
include_bytes!一个文件 154 347 680 字节(147.2 MiB)5.1 s

编进去的二进制里,__TEXT 段的 __const 节是 148 301 968 字节,就是那两个文件。启动时间和内存(每种 10 次,每次写入一个请求后关闭 stdin;ready 是程序自己报告的「加载加预热完成」):

带法设备进程墙钟 最小 / 中位ready 最小 / 中位峰值 RSS
读文件CPU0.20 / 0.20 s111.7 / 114.2 ms323.6 – 326.3 MiB
mmapCPU0.18 / 0.18 s90.8 / 92.1 ms431.8 – 432.2 MiB
include_bytes!CPU0.18 / 0.18 s91.1 / 92.6 ms460.7 – 463.1 MiB
读文件Metal0.31 / 0.31 s182.3 / 187.5 ms463.3 – 465.1 MiB
mmapMetal0.29 / 0.30 s169.4 / 171.8 ms553.8 – 554.8 MiB
include_bytes!Metal0.29 / 0.30 s169.7 / 173.6 ms601.2 – 603.1 MiB

(负载 2.0 到 3.2。)

  • 三种的速度差不到 25 ms。 mmap 和 include_bytes! 一样快,都比 BufReader 快约 20 ms。
  • RSS 多出来约一个文件的大小。 不管哪种带法,from_gguf 都把每个张量的字节复制进自己的存储(CPU 上的 QTensor、Metal 上的缓冲区),源文件只是被读一遍。用 read 读文件,读过的页留在系统的页缓存里,不算进进程的 RSS;mmap 和编进二进制的字节是映射进进程的页,读过就算进去,所以多了 90 到 140 MiB。这和 safetensors 一侧 from_mmaped_safetensors 的情况一样(什么时候选 candle 的动脑筋答案)。transformers 5 加载 bf16 时是零拷贝的,见 safetensors。
  • include_bytes! 的代价在构建和更新上。 每次增量构建要多 2.7 秒(链接一个 147 MiB 的文件),换一次权重就要重新编译、重新分发整个程序。它换来的是「只有一个文件」,启动不比 mmap 快。

所以:权重放在程序旁边,用 BufReader 读(RSS 最小)或 mmap(快 20 ms);只有确实需要单文件分发时才编进去。

怎么选

  • 程序本身:strip = true 就够了,lto = "fat"、codegen-units = 1、panic = "abort" 在这个程序上只让 CPU prefill 变慢、构建变久,decode 不变。
  • 链接的只有 macOS 自带的框架;换 Linux 要关掉 metal、accelerate,CPU 速度回到 gemm,交叉编译还要先有目标平台的 C 工具链(candle-core 0.11.0 依赖 onig)。
  • 权重放在程序旁边;需要单文件时用 include_bytes! 加 Cursor,代价是 147 MiB 的二进制和更慢的增量构建。
  • 服务形态:读一行、套模板、生成、每段 flush;启动时预热,eos 和多字节字符自己处理。对 transformers 的 TextStreamer,candle 没有现成的对应物,二十来行就能写完。

小结

  • 一个读 stdin、流式输出的 candle 服务(Q8_0 GGUF):到 ready 0.1 到 0.2 秒,Metal 上每个 token 约 2.5 ms 到达;transformers 的同一个服务到 ready 约 1.7 秒、峰值 RSS 1121 MiB。
  • release profile:只有 strip = true 值得开(8.05 → 6.40 MiB,构建时间不变);四项全开 5.04 MiB、构建 85 秒;decode 速度九种构建都一样,CPU prefill 在 fat LTO、codegen-units = 1、panic = "abort"、opt-level = "s" 下慢 13% 到 34%,多出的时间落在 where_cond 上。
  • otool -L:Metal、Foundation、CoreFoundation、libobjc、Accelerate、libiconv、libSystem,全是系统自带;不开 accelerate 只少 Accelerate。
  • Linux:没有 Metal 和 Accelerate;mkl、cuda 没有跑;从 macOS 交叉检查卡在 onig_sys 要目标平台的 C 编译器,origin/main 已换成纯 Rust 的 fancy-regex。
  • 权重:放旁边读文件 RSS 最小,mmap 和 include_bytes! 快约 20 ms、RSS 多约一个文件;include_bytes! 让二进制变成 147 MiB、增量构建多 2.7 秒。

💬 关联讨论

发起讨论
加载中...