最近在用 Rust 写一个 Alfred 工作流,写完后发现体积有点大,于是查找了一些减少编译体积的方法,在此记录一下。

编译器版本:

1
2
> rustc --version
rustc 1.79.0-nightly (4d570eea0 2024-04-26)

1.使用 Release 构建

Cargo 默认使用 debug 模式编译,这个模式下没有进行任何优化,并且附加了大量的调试信息,所以程序体积比较大。如果采用 release 模式下会对程序进行优化,也不会包含调试信息,详情可以参考 cargo book 或者 rust book.

我的程序也不是很大,在 debug 模式大概有 2.7MB,使用 release 模式下能够减少到 2.1 M,大概减少了 22%. 因为我的程序原本就不大,所以 20% 的减少量还是挺可观的。如果程序比较大,这个体积的减少量会更大。

2. 修改 Profiles

2.1 调整优化等级

Cargo 中提供了 profiles 用来修改一些编译设置,其中可以使用 opt-level 来调整优化等级。下面是一些可选的值,具体可以查看 cargo book

  • 0: no optimizations
  • 1: basic optimizations
  • 2: some optimizations
  • 3: all optimizations
  • "s": optimize for binary size
  • "z": optimize for binary size, but also turn off loop vectorization.

优化等级越高,程序体积就越小,但是运行得越慢。因为我的程序只是一个 Alfred workflow,不需要他运行得多快,所以我将优化等级设置为了 ‘z’。

1
2
[profile.release]
opt-level = 'z'

调整优化等级后我的程序体积减少到了 1.4 M,相比之前减少了大概 33%,很 nice。

2.2. 开启 LTO

LTO(Link Time Optimization),它控制了 rustc-C lto, -C linker-plugin-lto-C embed-bitcode 选项,即控制了 LLVM 的链接时优化,从而消除大量冗余代码,但是代价是更长的链接时间。下面是一些有效的值,集体可以参考 cargo book

  • false: 只对本地的 crate 进行优化
  • true / fat: 尝试对依赖的所有的 crates 进行优化
  • thin: 与 fat 相似,但是运行时间大量减少
  • off: 禁止 LTO

这里采用 true,不过尴尬的是几乎没有什么提升,大概减少了 5%

2.3. Panic 时立刻终止

panic 选项可以控制 -C panic flag,控制了使用的 panic 策略。panic 有下面两种可能的值,具体可以参考 cargo book

  • "unwind": panic 时进行栈展开
  • "abort": panic 是立即终止

使用 abort 不利于修改 bug,不过我的程序很简单,于是直接采用 abort 策略了。

1
2
3
4
[profile.release]
lto = true
panic = 'abort'
opt-level = 'z'

程序体积减小到了 1.3MB,感觉还行

2.4. 调整 codegen-units

codegen-utils 控制着 -C codegen-units flag, 控制着一个 crate 执行代码生成时使用的线程数量, codegen-utils 越大,就意味着编译时间越少,但是可能产生的代码运行得更慢(因为会妨碍某些优化). 这里我们采用 1,体积大概减少了 0.03% 😂

2.5 使用 strip

strip 控制 -C strip flag,即控制是否在二进制文件中保留符号链接、调试等信息,可选值为 none / false, debuginfo, symbols / true。详情可以查看 cargo book

这里我是用 symbols,程序体积减少了大概 10 %,较少到了 1.2 MB

感觉这里和使用 strip 命令是一样的效果

1
strip target/release/alfred-zed

3. 减少依赖

可以使用 cargo deps | dot -Tpng > dep.png 来查看依赖图,不过这只能看到依赖图。如果想要知道某个 crate 占据了多大体积,可以是使用 cargo-bloat

我的程序使用到的依赖如下:

1
2
3
4
[dependencies]
serde = { version = "^1.0", features = ["derive"] }
serde_json = "1.0"
rusqlite = { version = "0.31.0", features = ["bundled"] }
1
2
3
4
5
6
7
8
9
10
11
12
>  cargo bloat --release --crates

File .text Size Crate
52.6% 72.3% 740.0KiB [Unknown]
16.3% 22.4% 229.7KiB std
3.4% 4.7% 48.4KiB libsqlite3_sys
0.5% 0.7% 6.8KiB alfred_zed
0.4% 0.5% 5.1KiB rusqlite
0.2% 0.2% 2.3KiB serde_json
0.0% 0.0% 98B hashlink
0.0% 0.0% 33B serde
72.8% 100.0% 1.0MiB .text section size, the file size is 1.4MiB

可以看到,使用 cargo-bloat 是体积增大了,不过没有关系,我们只是想要知道 crates 占据的大致大小和比例。

从输出结果可以看到, [Unknown] 占据了绝大多数的体积,我们可以使用 --unknown 选项查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
>  cargo bloat --release --unknown
File .text Size Crate Name
2.6% 3.6% 36.5KiB [Unknown] _sqlite3VdbeExec
1.7% 2.3% 23.4KiB [Unknown] _yy_reduce
1.6% 2.2% 22.7KiB [Unknown] _sqlite3Select
1.2% 1.6% 16.3KiB std std::backtrace_rs::symbolize::gim...
1.0% 1.3% 13.8KiB [Unknown] _sqlite3Pragma
0.9% 1.3% 13.2KiB [Unknown] _sqlite3WhereBegin
0.7% 1.0% 10.5KiB std addr2line::ResUnit<R>::find_funct...
0.7% 1.0% 10.5KiB std std::sys_common::backtrace::_prin...
0.7% 1.0% 10.1KiB std gimli::read::dwarf::Unit<R>::new
0.7% 0.9% 9.4KiB [Unknown] _sqlite3Insert
0.6% 0.8% 8.4KiB [Unknown] _sqlite3Update
0.6% 0.8% 7.9KiB libsqlite3_sys _sqlite3_str_vappendf
0.6% 0.8% 7.8KiB std addr2line::Lines::parse
0.6% 0.8% 7.7KiB [Unknown] _balance
0.5% 0.7% 7.6KiB [Unknown] __mh_execute_header
0.5% 0.7% 7.1KiB [Unknown] _resolveExprStep
0.5% 0.7% 6.8KiB alfred_zed alfred_zed::main
0.5% 0.7% 6.8KiB [Unknown] _sqlite3Fts3Incrmerge
0.5% 0.6% 6.5KiB [Unknown] _exprAnalyze
0.4% 0.6% 6.1KiB [Unknown] _sqlite3ExprCodeTarget
56.4% 77.5% 793.2KiB And 3059 smaller methods. Use -n ...
72.8% 100.0% 1.0MiB .text section size, the file size...

这里看到 unknown 很多,不过大致可以猜到是和 sqlite 相关,所以我们把问题定位到 rusqlite。(而且从依赖图中也能看到它依赖了很多东西。)

于是我决定用 sqlite 替换 rusqlite,程序体积减少到了 326KB,非常 nice,体积减少了 75% 左右。

禁用不必要的 feature

这一点我的程序没有用到,不过我之前写的另一个工作流用到了。我但是是使用的 regex 库,禁用了他的默认 features

1
regex = { version = "1.10", default-features = false, features = ["std"] }

4. libstd 优化

Rust 的工具链自带了预编译的标准库(libstd),即每次编译 Rust 程序时直接把 libstd 静态链接进去,但是这样开发者就没的选了

xargo 提供了一个功能,允许我们自定义 std。我们只需要新建一个 Xargo.toml 文件,然后写入想要优化的依赖就行

1
2
3
# Xargo.toml
[target.x86_64-apple-darwin.dependencies]
std = { default-features = false }

然后编译时使用下面的命令:

1
> xargo build --target x86_64-apple-darwin --release

最终编译后的程序大小为 132KB

总结

总体来说,大部分的优化在 cargo book 中都有描述,有时间要看一下 cargo book. 然后就是编译时间、运行速度和编译体积之间大小的取舍,这还是要需要一定的经验的,需要慢慢积累

参考资料: