我喜欢 Rust 已经很久了。它的优点很突出,缺点也同样明显:无需垃圾回收即可保证内存安全、零成本抽象、并发安全、几乎是现阶段最好的工具链及周边生态、覆盖面极广(上至 Web 云服务器,下至单片机),而且错误处理机制设计得非常出色。至于缺点,是学习曲线陡峭?所有权、借用之类的模型约束难以理解?还是写代码太慢?

长期以来,我们一直认为这些是 Rust 的缺点。但实际上,前两点都可以通过训练思维、提升认知来克服,唯独缓慢的编译速度始终拖累着我的工作流。放在几年前,这一切似乎还好;在 AI 浪潮席卷而来之前,我有的是耐心,常常为了一个算法、一个功能写到凌晨。可现在呢?我很难说自己没有变:我开始变得一点耐心都没有,既不想思考 Rust 代码该怎么写,也完全等不了 Cargo 那条漫长的进度条。尤其到了 ld 链接阶段,连进度都看不到,每次只能眼睁睁看着它卡在那里,实在太难受了。

这一切难道就没有解决办法吗?其实是有的。大约一个月前,我尝试了包括优化工作流在内的一些办法,希望加快 Rust 开发流程,但等待时间其实总是卡在 Cargo 上;不解决这个问题,就只是治标不治本。正好借此机会,简单聊聊 Rust 开发中 Cargo.toml 的配置调优及其配套的外围配置。

后端原理

Rust 的设计里选用 LLVM 其实有点偷懒,但也选得很好。它不像 Go 那样自己维护一整套到机器码的流程,而是把成熟的优化交给 LLVM 这个大生态来做,这其实很好。整个 Rust 的编译流程大致是这样:源码先跑一遍词法分析和语法分析生成 AST,再经过宏展开、降级到 HIR 拍平!然后进入常规的类型检查、借用检查,最终生成 MIR。到这里为止,都是 Rust 编译器自己的前端在干活。而 MIR 之后的事情就交给了 LLVM:把 MIR 翻译成 LLVM IR,然后 LLVM 跑一遍它那套优化 pass(从 O0 到 O3 不等),最后生成目标平台的机器码,再交给链接器拼成 BIN 可执行文件。

.rs sourceLexer + Parser→ ASTMacro expansionDesugaringHIR loweringName resolutionType checkingTrait solvingBorrow checkerLifetime validationMIR generationCFG,const eval,inliningLLVM IR codegenTranslate MIR → IRLLVM optimizationsO0 ~ O3 passesMachine codeTarget-specificLinkerSystem linkerExecutable binary

那么,问题到底出在哪里?答案其实很简单:LLVM 是一套非常庞大的基础设施,这种重量级设计在生产环境中是合理的——用编译时间换取运行效率,是一种很好的理念。它承接着 Rust、C、C++、Swift 等众多上层语言,这意味着它的优化管线是以“生成最优机器码”为目标设计的,而不是为了“快速完成编译”。在发布模式下,依次运行几十个优化阶段非常耗时;即便在调试模式的 O0 优化级别下,LLVM 仍然要走完生成中间表示和机器码的完整流程,而这套流程本身就不轻量。再加上 Rust 会在编译期间对泛型进行单态化,展开出大量代码,最终交给 LLVM 的中间表示规模远比源码看起来大得多,因此 LLVM 的工作量自然也会随之膨胀。

Cranelift

所以本质上,编译慢的绝大部分时间并不花在 Rust 自己的前端,而是花在 LLVM 这个"重型后端"上。那么问题来了:有没有可能干脆把它丢掉呢?答案是有的,有一个叫做 Cranelift 的后端就是为这个需求做的。它最早叫 Cretonne,2016 年启动,由 Bytecode Alliance 开发,最初是Wasmtime 的专用代码生成后端;后来才被 Rust 官方收编,成为可选的 codegen 后端。

A low-level retargetable code generator. , github.com, 在新标签页中打开
Cranelift 项目落地页的浏览器截图,左上角是 Bytecode Alliance 的 logo,导航栏依次为 Documentation、API Reference、Contributing、Chat 以及一个 GitHub 图标。在大标题 "Cranelift" 下方,页面将其描述为一个 Bytecode Alliance 项目:一个快速、安全、相对简单且富有创新性的编译器后端,它接收来自某个前端的中间表示并将其编译为可执行机器码,作为库在 "embedder" 内部使用——尤其是用于 JIT 和 AOT 编译的 Wasmtime WebAssembly 虚拟机,以及作为 Rust 编译器的实验性后端——其本身用 Rust 编写。第二段列出支持的平台为 x86-64、aarch64 (ARM64)、s390x (IBM Z) 和 riscv64,并指出它可重定向,欢迎贡献更多 ISA;第三段称其处于积极维护中,并已用于生产环境,以接近原生的性能运行沙箱化的不受信任代码,遵循 Wasmtime 的发布策略和安全策略。该截图似乎是项目官方自我描述的凭证,可能是作为 Cranelift 声称支持内容的参考而保存的。

那么,Cranelift 究竟快在哪里呢?它最适合用在开发阶段:这时你也许根本不需要近乎完美、将执行效率优化到极致的机器码,只需要快速获得反馈;只要质量稍逊的机器码与 LLVM 正式生成的机器码在语义上等价就够了。早期的 Cranelift 因为存在许多边缘情况,其实还做不到这一点;如今虽然仍有问题,但已经改善了许多。现在,代码能在 Cranelift 下运行、却在 LLVM 下出错的概率,已经和你用 Rust 写代码时触发 rustc 内部编译器错误差不多了。为了生成优化到极致的机器码,LLVM 会执行几十轮优化,例如循环展开、向量化、常量传播、死代码消除等……数量繁多,一层层反复打磨。Cranelift 的设计理念则截然不同:它大幅精简优化步骤,只进行最基本的寄存器分配和指令选择,通过一次线性扫描完成代码生成,不再反复迭代。同时,它的中间表示设计也更加轻量,专门用于将上层中间表示快速转换为机器码,不像 LLVM 的中间表示那样背负着几十年积累下来的通用化包袱。

MIRLLVM IRMulti-layer IR100+ opt passesO0 ~ O3,LTOMachine codeComplex reg allocLinkMIRCLIF IRSingle-layer IRLightweight optsFewer passesMachine codeSimple reg allocLink

好处说完了,代价呢?最明显的一条是 Cranelift 生成的代码运行起来比 LLVM 慢,视场景不同大约慢 10%~30%。但想开了就会发现,这在开发阶段根本不重要。我要那么快干嘛?我是在跑分,还是在做 CI 的 release 构建?我 就是要 cargo build 快一点,好看看逻辑对不对。而且我敢打赌,你写的大部分程序不可能有 80% 以上的时间把计算资源占满,总得有业务进来才行,而开发的时候能有多少业务?CPU 怕是全程都在 ,反倒是我这边要看效果、要验证逻辑。那么拿编译时间换运行效率,这笔账放进开发循环里,是不是应该反过来算?

代码生成单元

另外说说 codegen-units:它控制编译器将 crate 拆分成多少个最小单元,交给后端并行处理;默认情况下,调试模式为 256 个,发布模式为 16 个。数字越大,并行度越高,编译也越快,因为后端代码生成可以同时利用多个处理器核心。代价则是优化效果变差:每个单元都独立优化,LLVM(或 Cranelift)能看到的上下文更少,跨单元内联和优化的机会也随之减少。

MIRCGU 1CGU 2CGU 3CGU …NLLVM opts + codegenLLVM opts + codegenLLVM opts + codegenLLVM opts + codegen.o.o.o.oLink

但在真正的 Release 构建中,通常应该使用另一个极端的参数 codegen-units = 1。只有这样才能最大限度地优化产物;毕竟都用 Rust 了,牺牲编译时间来换取运行时性能,不是理所当然的吗?

优化级别

另一个常被忽略但可以调整的配置是 opt-level,其默认值应该是“3”;在发布模式下,更常见的做法是设为“z”,这样无需修改代码就能白白获得产物体积优化,何乐而不为呢?不过在开发模式下,这里应该设为“0”,完全禁用优化才能获得最快的编译速度。

另外还有一个小技巧:Cargo.toml 实际上可以为依赖项和自己的代码分别设置不同的优化级别。

[profile.dev.package."*"]
opt-level = 3

这样就可以用 O3 编译第三方依赖。虽然这会延长首次冷编译的时间,但后续采用增量编译,而外部依赖通常也不会频繁更新。第三方依赖用 O3、业务代码用 O0,一般能在编译速度和产物体积之间取得良好平衡,带来不错的开发模式体验;不过在发布模式下,我仍然建议直接拉满到 O3 或 Z,没什么好说的。

LTO(链接时优化)也是非常消耗内存和拖慢编译速度的功能,但大多数项目都建议在发布构建时启用。常规编译会分别优化每个 crate,编译后端无法看到 crate 之间的调用关系,因此无法完成一些跨 crate 的内联和死代码消除,而这类情况又非常常见。LTO 会打破这一边界,让优化器在链接阶段获取所有 crate 的中间表示并进行一次全局优化。不过,千万不要不加考虑地直接给所有构建配置启用 lto = true,否则 Rust 的编译速度会慢到让你怀疑人生;仔细检查一下,确保只在使用发布配置时启用它即可。

移除调试符号

和其他内容一样,Rust 的调试信息和符号也存放在这里;在 release 模式下启用 strip 可以显著减小体积(通常能从 50 MB 降到 5 MB),差距大得离谱。移除符号也更加安全,毕竟前端源代码大多都是因为有人不小心把 source map 推送到 npm 上而泄露的 :(

所以,开发配置下没什么可说的:你总不能把调试信息剥离掉,否则就无法使用 gdb / lldb 正常调试,调用栈回溯中也看不到有意义的函数名。不过要注意,Arch Linux 打包时默认会帮你执行剥离,这就会和我们的做法产生一点冲突:我们已经剥离过了,后续操作可能会报错。因此,编写 AUR 包时可以显式跳过这一步。

Full binary.text.rodata / .data.symtab.debug_* (DWARF).eh_frame (unwind)strip=debuginfo.text.rodata / .data.symtab.debug_*.eh_frame (unwind)strip=symbols.text.rodata / .data.symtab.debug_*.eh_frame (unwind)+ panic="abort".text.rodata / .data.symtab.debug_*.eh_frame

告别 panic!

在正常业务中,panic 本就不该存在。业务代码在运行时遇到错误时,凡是已经设计了容错机制的 Err,都应该安全返回,而不是粗暴地触发 panic。就像 React ErrorBoundary 一样,它们应当作为普通错误得到处理;只有当代码确信自己进入了理论上不可能出现且无法挽回的状态时,才应该触发 panic。我个人的理念是放弃 panic。为什么?因为业务代码实际上可以分三层测试:第一层是正常路径,第二层是错误路径(无限种可能中的某一种),第三层才是通过模糊测试或碰撞测试穷举出的边界情况。从工程角度看,正常路径可以做到 100% 覆盖;对于一类错误路径的 n 种变体,只需选取一种测试;碰撞测试则纯粹是时间问题,大多数时候并不值得做。等用户规模真正上来、确实需要做时,你自然会知道;通常我一概不做。

因此,好的代码应该覆盖一类正确路径测试和至少一种错误路径测试。对于同一类错误路径,只需覆盖一个变体,就能验证错误能否正常返回,并据此编写兜底逻辑,自然也就可以用 thiserror 逐项处理,或用 anyhow 统一拦截、拍平、打印和记录。总之,代码会迫使你建立处理这些情况的机制。做到这一层后,panic 对你就没有用了:它可以被理解为“理论上不可能发生”,但也仅仅是理论上如此。现实世界中仍然存在操作系统错误、内存错误、宇宙射线导致的单比特翻转,以及各种稀奇古怪的溢出……你永远不可能覆盖所有这类情况。那么,为什么不使用恐慌机制呢?因为这些情况很可能——有 90% 的概率——并不是你的代码导致的。panic 的本质是:发生恐慌时,Rust 会沿调用栈逐层回退,依次调用每个栈帧中的析构函数,清理资源。为此,编译器需要生成额外的 unwind 表,以便定位业务逻辑中的错误。但如果错误本身很可能不在你的代码里,这些信息又有什么价值?保留它们不仅会增大编译产物,还会增加链接器的工作量,因此关闭才是正确选择。如果你的代码已经经过充分测试,也有足够的信心,我建议配置 panic = abort

实测

多说无益,还是来看看这些优化组合起来后的实际提升吧。这里以我几个月前做的一个小玩具项目为例。

CodeCommentsBlanks
Total files 980 Total lines 98k Code lines 78k Comment ratio 8% tokei

这个项目的 Rust 部分约有 3.2 万行源代码,不包括依赖项。

Seam canmi21/seam
a7f34cb

Rendering is a protocol, not a render-time computation.

TypeScript 39 stars 1 forks MIT 1 open issues Jul 3, 2026

这个仓库其实是一个包含许多子项目的单体仓库,其中有两个典型例子。先看 Skeleton 包,它的特点很鲜明:代码量大、依赖极少,而且代码生成部分在整个项目中占比最高,非常适合 Cranelift 发挥作用。此外,这个包也很干净,也就是说,它完全使用安全 Rust 编写。

反观下面这个 CLI 包就不太适合。依赖多本身倒不是问题,理论上反而更能体现编译效率;但这里存在一个典型的二选一:从左上角可以看到,ring 这个依赖被标记为可选,因为项目原本使用的是 aws-lc-rs。两者都是 Rust 中的加密算法后端,之所以需要设为可选,是因为 ring 由纯 Rust 编写,而另一个则通过外部函数接口引入汇编代码,专门针对 x86、ARM64 等主流 CPU 架构进行汇编优化。可问题也恰恰出在这里:Cranelift 的优势仅限于纯 Rust;一旦引入不安全代码,或者通过外部函数接口调用 C、汇编代码等,Cranelift 的兼容性就会变得很差。

不过这个问题也不是无解:可以通过 cfg 选择后端,在 Cranelift 模式下使用 ring 编译即可。按照上面的说明配置好开发和发布配置后,就可以简单对比一下 CLI 的编译速度。在这种情况下,即使都是没有增量缓存的冷编译,开发版也至少是发布版的 3 倍快;再加上使用 O0 并关闭 LTO,后续增量编译时,开发配置的速度应该能达到发布配置的几十倍以上。因为 LTO 之类的优化实际上依赖于打破各个 crate 之间的边界,将它们拍平成一个整体;这样一来,只要修改一处代码,整个程序就都得重新编译,无法正确进行增量更新。

一张深色 macOS 终端窗口的截图,标题为 `~/C/P/seam`,画面中是一次 Cargo release 构建的收尾部分:约四十行青色的 "Compiling" 记录,列出了带版本号的第三方 crate —— walkdir v2.5.0、rand v0.10.0、tungstenite v0.29.0、sha2 v0.10.9、clap v4.6.0、rquickjs v0.11.0、notify v8.2.0、indicatif v0.18.4 —— 随后是项目自身的 workspace 成员,版本均为 v0.5.38,各自标注了路径:位于 `/Users/canmi/Canmi/Project/seam/src/` 下的 seam-codegen、seam-skeleton、seam-injector-wasm、seam-engine-wasm、seam-server、seam-server-axum 和 seam-cli,接着是四个 v0.0.0 的示例 crate(demo-server-rust、github-dashboard-axum、markdown-demo-rust、i18n-demo-axum)。最后一行显示 `Finished \`release\` profile [optimized] target(s) in 1m 05s`,下方是等待输入的 shell 提示符 `canmi@xyy ~/C/P/seam (main)>`。它记录了整个 seam workspace —— 库、server adapter、CLI 以及全部随附示例 —— 在 main 分支上一次干净、无警告的 release 构建,耗时刚过一分钟。 一张 macOS 终端截图,标题为 `~/C/P/seam`,显示一次成功 Cargo 构建的末尾部分:约三十行青色的 "Compiling" 输出向上滚动,先是第三方 crate(console v0.16.3、reqwest v0.13.2、clap v4.6.0、tokio-tungstenite v0.29.0、notify v8.2.0、wasm-bindgen-macro v0.2.114),最后是 v0.5.38 的本地 workspace 成员——seam-codegen、seam-server-axum、seam-skeleton、seam-injector-wasm、seam-engine-wasm 和 seam-cli——其后是 `examples/` 目录下四个 v0.0.0 的示例 crate(github-dashboard rust-axum backend、i18n-demo backend、markdown-demo server-rust、standalone server-rust)。最后一行是 `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 19.23s`,提示符回到 `canmi@xyy ~/C/P/seam (main)>`。它证明 seam workspace——一个拆分为 CLI、server-adapter、codegen 和 WASM engine/injector crate 以及若干示例后端的 Rust 项目——在 `main` 分支上能够干净地完成构建,用时约二十秒。

综合来看,这两者其实相差不大,谈不上质变,但差异绝对能被明显感知。主要原因是 Apple Silicon 的性能实在太强:M 系列芯片的单核性能和内存带宽都高得惊人,而编译这种既依赖大量计算、又依赖大量 I/O 的任务,恰好能充分利用这些优势。通常在 Linux 上运行时,差距会更大;但如果是在同一台 MacBook 上安装 Asahi Linux 之类的系统来比较,那肯定是 Linux 胜出。

老旧链接器

在 Linux 上,Rust 默认使用 GNU ld(bfd),没错,就是那个最老、最慢的链接器。它采用单线程,符号解析和重定位也都是串行扫描;项目一大,链接速度就会明显拖后腿。可以在 Linux 上试试 @Rui Ueyama 编写的 mold,配置起来很简单,只需在.cargo/config.toml中加一行即可。

[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
一张分组柱状图,比较四款 Unix linker——GNU ld 2.42(蓝色)、GNU gold 2.38(红色)、LLVM lld 19.0.0(黄色)和 mold 2.4.0(绿色)——的链接耗时,y 轴为秒数,范围 0 到 50,x 轴列出三个程序及其二进制体积:MySQL 8.3 为 0.47 GiB,Clang 19.0 为 1.56 GiB,Chromium 124 为 1.35 GiB。MySQL 的四根柱子大致为 11、7.5、1.7 和 0.5 秒;Clang 为 42、33、5.3 和 1.4;Chromium 则完全没有蓝色柱子,gold 约 27,lld 约 6,mold 约 1.4。差距随二进制体积增大而拉开,缺失的 GNU ld 柱子意味着它未能完成 Chromium 的链接,因此该图可解读为一份证据:在大型构建中,mold 比 lld 快约一个数量级,比传统 GNU linker 快二三十倍。

速度提升还是很明显的。遗憾的是,macOS 上只有 sold 这个商业项目;好在 lld 和 Apple 自带的 ld64 本身已经不慢,尤其是 Xcode 15 之后的新链接器,速度提升非常明显。不过它有个缺点:macOS 无人值守 CI 每次更新后都必须用 sudo 接受一遍条款,害得我的 CI 定时任务挂掉了好几次。

MUSL 与 glibc

我本人是个 ,非常讨厌 GNU glibc,但开发阶段我还是推荐你用 x86_64-unknown-linux-gnu。为什么呢?因为快。MUSL 最大的卖点是能生成完全静态链接的二进制——不依赖系统上的任何动态库,编译出来的东西拷到任何一台 Linux 机器上都能直接跑,特别适合容器和嵌入式场景。但慢也慢在静态链接上:链接器需要把所有东西都打包进去,链接阶段的工作量比动态链接大得多。

不过好消息是,如果你使用 macOS,就完全不用担心这个问题:目前只有 aarch64-apple-darwin 这一个选择。macOS 在设计上就不支持完全静态链接,从编译速度来看反而是件好事。因此,dev profile 在 Linux 上使用 glibc,在 macOS 上使用 libSystem;release profile 在 macOS 和 Linux 上都可以使用 musl。唯一需要注意的是,由于 macOS 默认选择链接器和 ar 的方式存在问题,你可能需要配置一下 .cargo/config.toml

[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
ar = "x86_64-linux-musl-ar"

另外,千万不要使用 Zig。nightly Rust 上的 zbuild 目前问题很多;需要交叉编译时,如果主机是 x86 Linux,推荐使用 cross,配合 Docker 编译非常方便,所需环境也一应俱全。如果你偏爱 glibc,这套方案同样很好用。由于 glibc 只保证向后兼容,通常应以 Debian 当前大版本往前推两个大版本,并使用该版本 Debian 所带的 glibc 进行编译;否则,过新的 glibc 会导致大量机器无法运行。这里并不是因为使用了什么新特性,纯粹只是检查了一下版本字符串就故意给你添堵。如果主机是 Mac,请使用 rustup 原生支持的 target + cargo build --release

UPX 有害

还有一点值得讨论,那就是是否使用 UPX。我知道,一边说自己喜欢 musl,一边又追求更小的体积,其实很矛盾,听起来也不太合理……

                 ooooo     ooo  ooooooooo.  ooooooo  ooooo
                 `888'     `8'  `888   `Y88. `8888    d8'
                  888       8    888   .d88'   Y888..8P
                  888       8    888ooo88P'     `8888'
                  888       8    888           .8PY888.
                  `88.    .8'    888          d8'  `888b
                    `YbodP'     o888o       o888o  o88888o


                    The Ultimate Packer for eXecutables
   Copyright (c) 1996-2026 Markus Oberhumer, Laszlo Molnar & John Reiser
                           https://upx.github.io

但实际上,UPX 的存在解决了这个问题。它的原理是先压缩二进制文件,再套上一层解压存根;运行时先在内存中解压,然后执行。这样通常可以把二进制文件压缩到原体积的 30%~50%,体积优势会非常明显。但也正因如此,UPX 与采用 GNU glibc 动态链接的程序结合时会产生严重问题:这类二进制文件包含一些特殊的节和动态加载机制,经过 UPX 压缩后,这些结构可能遭到破坏,导致程序运行时找不到动态库 segment fault。相比之下,musl 就非常适合 UPX:它本来就是完全静态链接,内部结构稳定,UPX 的压缩和运行时解压都很干净。因此,在 musl 的发布 CI 中额外加入 UPX 这一步,其实会非常加分。

另外,UPX 也不能滥用。它只适合长时间运行或低频执行的任务:UPX 启动时需要先在内存中解压,这个过程虽然全自动,但会略微增加冷启动时间。影响通常很小,在高频调用路径上却可能十分致命。因此,只启动一次便长期在后台运行的任务很适合使用 UPX;但我并不建议不加辨别地给 Docker 镜像中的常驻服务程序套上 UPX,因为这本质上是在用内存换磁盘空间,实在血亏,而且容器层本身也会压缩,同样能够减少分发时的下载体积。相比之下,我更推荐在某些 CLI 场景中使用 UPX:它能节省大量磁盘空间,启动时的解压延迟也几乎无感,而 musl 天然就很适合构建 CLI。说到这里,我不得不点名批评一下 AUR 助手:那个用 Go 写的家伙竟然没有静态链接,害我曾经遇到 Arch Linux 上的 yay 因找不到动态库而彻底罢工;偏偏它本身又是系统管理工具,根本无从修复,只能另行启动 Live ISO,最后通过 chroot 把系统救回来。

小结

最后,这些实用配置虽能帮你快速区分开发配置和发布配置,而且随着项目代码增多,两者的差异只会越来越明显,但我还是建议你另写一套持续集成流程,用 Rust 稳定版和 LLVM 后端构建,最好把各种测试也一并跑起来。这样,本地就不用再切换 Rust 夜间版了,边界情况也坑不到你;推送后若有问题,会直接被持续集成拦住。这就是目前最舒服的开发者体验模式。那这次就说到这里,下次有空再折腾吧。