我花了一个月的时间在 Docker 中反复构建我的网站,现在要分享一些可怕的经历。
我遇到了一个问题。
我的网站(您正在阅读的这个网站)主要由一个 Rust 二进制文件提供服务。长期以来,每当我想进行更改时,我都会:
- 构建一个新的静态链接二进制文件(使用
--target=x86_64-unknown-linux-musl
) - 将其复制到我的服务器
- 重新启动网站
这……不太理想。
因此,我希望改用容器(无论是 Docker、Kubernetes 还是其他)来部署我的网站,这与过去十年中部署的大多数软件相吻合。
唯一的问题是,使用 Docker 快速构建 Rust 并不简单。
更新(2025-06-27)
我最初在Bluesky上发布了这篇文章链接——那里有一些有价值的讨论 ❤️
特别感谢Piotr Osiewicz和Wesley Moore的建议,这些建议节省了大量时间。
更多相关内容请见文末部分。
该文章还被转发到 r/rust 和 hackernews。如果你有兴趣的话,可以看看这些有趣的评论。
目录:
- 基础知识:Docker 中的 Rust
- rustc 到底在做什么?
- 这次真的问问
rustc
吧 - 是时候谈谈 LTO 了
- 简短说明:50 秒其实是 可以接受的!
- 另一条简短说明:我们不能用增量编译吗?
- 深入探讨:连你也要这样,
LLVM_module_optimize
? - LLVM到底是怎么回事?
- LLVM的跟踪事件中包含什么?
- 能否让
InlinerPass
更快? - 能否让
OptFunction
更快? - 整合所有内容
- 2025 年 6 月 27 日更新
- 最终总结
基础知识:Docker 中的 Rust
Docker 中的 Rust,简单的方法
要将 Rust 程序放入容器中,通常的做法如下:
FROM rust:1.87-alpine3.22 AS builder
RUN apk add musl-dev
WORKDIR /workdir
COPY . .
# the "package" for my website is "web-http-server".
RUN cargo build --package web-http-server --target=x86_64-unknown-linux-musl
# Only include the binary in the final image
FROM alpine:3.20
COPY /workdir/target/x86_64-unknown-linux-musl/release/web-http-server /usr/bin/web-http-server
ENTRYPOINT ["/usr/bin/web-http-server"]
遗憾的是,每当有任何更改时,这都会从头开始重建一切。
就我而言,从头开始构建大约需要 4 分钟(包括每次下载 crates 所需的 10 秒钟)。
$ cargo build --release --target=x86_64-unknown-linux-musl --package web-http-server
Updating crates.io index
Downloading crates ...
Downloaded anstream v0.6.18
Downloaded http-body v1.0.1
... many more lines ...
Compiling web-http-server v0.1.0 (/workdir/web-http-server)
Finished `release` profile [optimized + debuginfo] target(s) in 3m 51s
当然,情况可能会更糟糕。但由于增量编译,我已经习惯了快速的本地构建——我不想为每一个微小的更改等待那么久!
Docker 中的 Rust,更好的缓存
幸运的是,有一个工具可以帮助我们做到这一点!
Luca Palmieri 的 cargo-chef
允许您将所有依赖项作为 Docker 构建缓存中的独立层预先构建,这样代码库中的更改只会触发代码库本身的重新编译(而非依赖项)。
详细解释我留到 Luca 的博客文章 中,但大致来说,cargo-chef
会从当前的工作区创建一个简化的“配方”文件,该文件可以“烹饪”以缓存依赖项,而不会因工作区的更改而失效。
我的网站引入了数百个依赖项,因此这应该会有所帮助!
...
FROM ... AS planner
COPY . .
RUN cargo chef prepare --recipe-path=/workdir/recipe.json
FROM ... AS cooker
# NOTE: changes to the project can produce the same "recipe",
# allowing this build stage to be cached.
COPY /workdir/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path=/workdir/recipe.json
--target=x86_64-unknown-linux-musl
# If recipe.json is the same, 'cooker' will be cached.
# All that's left is compiling the final binary.
FROM cooker AS builder
COPY . .
RUN cargo build --release --package web-http-server
--target=x86_64-unknown-linux-musl
然而,它并没有带来我们期望的速度提升——大部分时间仍然花在最终二进制文件上:
$ # Build dependencies
$ cargo chef cook --release ...
Updating crates.io index
Downloading crates ...
...
Compiling web-http-server v0.0.1 (/workdir/web-http-server)
Finished `release` profile [optimized + debuginfo] target(s) in 1m 07s
$ # Build the final binary, using cached dependencies
$ cargo build --release ...
Compiling web-http-server v0.1.0 (/workdir/web-http-server)
Finished `release` profile [optimized + debuginfo] target(s) in 2m 50s
奇怪的是,只有 25% 的时间实际上花在依赖项上!就我所知,我的代码并没有做任何根本不合理的事情。它大约有 7000 行代码,用于将各种较大的依赖项(如 axum
、reqwest
、tokio-postgres
等)拼接在一起。
(为了确认,我尝试使用--verbose
参数运行cargo build
。结果确实只是单次调用rustc
就耗时近3分钟!)
rustc
在这段时间里都在做什么?
按照 fasterthanlime 的这篇精彩文章,我首先尝试使用 cargo --timings
来获取更多信息:
$ cargo build --release --timings ...
Compiling web-http-server v0.1.0 (/workdir/web-http-server)
Timing report saved to /workdir/target/cargo-timings/cargo-timing-20250607T192029.207407545Z.html
Finished `release` profile [optimized + debuginfo] target(s) in 2m 54s
除了 cargo-timing-<timestamp>.html
文件外,还有一个 cargo-timing.html
。我们只需复制标准版本:
...
FROM cooker AS builder
COPY . .
RUN cargo build --timings --release --target=x86_64-unknown-linux-musl --package web-http-server
# NEW: Move the cargo timings to a known location
RUN mv target/cargo-timings/cargo-timing-*.html cargo-timing.html
FROM alpine:3.22
COPY /workdir/target/x86_64-unknown-linux-musl/release/web-http-server /usr/bin/web-http-server
# NEW: Include it in the final image
COPY /workdir/cargo-timing.html cargo-timing.html
经过一些容器操作…
id="$(docker container create <IMAGE>)"
docker cp "$id:/cargo-timing.html" cargo-timing.html
docker container rm -f "$id"
我们应该能看到发生了什么!来看看:
哦. 那里其实没有太多信息!
这里发生了什么?
cargo build --timings
显示了一堆关于 每个 crate 的编译时间 的信息。但在这里,我们只关心最终 crate 的编译时间!
暂且不提这些,这确实有助于我们获得更准确的计时结果。在编译器外部进行测量会增加一些额外的变量,或者需要查找cargo build
的输出结果——因此,使用cargo
自行报告的计时数据将使后续的精确分析变得更加容易。
为了确认,这里的174.1秒大致与cargo build
输出中的“2分54秒”相符。
这次直接询问 rustc
fasterthanlime 的帖子中还有一个可用技巧——通过 -Zself-profile
标志使用 rustc
的自我剖析功能。
通常,你可能会运行类似以下命令:
RUSTC_BOOTSTRAP=1 cargo rustc --release -- -Z self-profile
_(注:此处使用 cargo rustc
向 rustc
传递额外标志,并通过 RUSTC_BOOTSTRAP=1
允许在稳定版编译器上使用 -Z
不稳定标志。)_
遗憾的是,此方法在此处无效——参数的更改会使 cargo chef cook
生成的缓存依赖项失效,且通过 cargo-chef
无法以等效方式传递额外的 rustc
标志。
相反,我们可以将所有标志通过 RUSTFLAGS
环境变量传递:
# cargo chef:
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Zself-profile' cargo chef cook --release ...
# final build:
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Zself-profile' cargo build --release ...
这将生成类似 web_http_server-<随机数字>.mm_profdata
的文件,我们可以像处理 cargo-timing.html
一样将其从镜像中移动并提取。
(注:如果在最终构建前移除 cargo chef cook
添加的性能分析数据,自动化流程会简单得多。此处为简洁起见省略了该步骤。)
实际使用 profdata
Rust 团队维护了一套用于探索 rustc
的自我分析输出的工具,位于 https://github.com/rust-lang/measureme。
一些关键工具:
summary
– 生成纯文本输出,总结性能分析数据flamegraph
– 生成 flamegraph SVGcrox
– 生成 Chrome 跟踪格式 跟踪,与chrome://tracing
(在基于 Chromium 的浏览器中)兼容
但让我们先安装几个这些工具,看看我们有什么:
cargo install --git https://github.com/rust-lang/measureme flamegraph summarize
我个人使用 Firefox,所以我们暂时先不处理 Chrome 跟踪相关的内容。
首先,使用 summarize
(它本身包含 summarize
和 diff
子命令):
$ summarize summarize web_http_server.mm_profdata | wc -l
945
$ summarize summarize web_http_server.mm_profdata | head
+-------------------------------+-----------+-----------------+----------+------------+
| Item | Self time | % of total time | Time | Item count |
+-------------------------------+-----------+-----------------+----------+------------+
| LLVM_lto_optimize | 851.95s | 33.389 | 851.95s | 1137 |
+-------------------------------+-----------+-----------------+----------+------------+
| LLVM_module_codegen_emit_obj | 674.94s | 26.452 | 674.94s | 1137 |
+-------------------------------+-----------+-----------------+----------+------------+
| LLVM_thin_lto_import | 317.75s | 12.453 | 317.75s | 1137 |
+-------------------------------+-----------+-----------------+----------+------------+
| LLVM_module_optimize | 189.00s | 7.407 | 189.00s | 17 |
thread 'main' panicked at library/std/src/io/stdio.rs:1165:9:
failed printing to stdout: Broken pipe (os error 32)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
(哎呀!典型的 CLI 边缘案例。不过很容易修复 😊)
从高层次来看,最重要的两点是链接时优化(LTO)和LLVM_module_codegen_emit_obj
,不管那是什么。
让我们看看能否通过火焰图更深入地了解情况:
$ flamegraph web_http_server.mm_profdata
$ # ... no output. Let's see what it added ...
$ find . -cmin 1 -type f # find files created less than 1 minute ago
./rustc.svg
太棒了,我们得到了一个 SVG!
因此,编译器生成和 LTO 之间似乎存在某种交互:codegen_module_perform_lto
最终会同时调用 LLVM_lto_optimize
/LLVM_thin_lto_import
和 LLVM_module_codegen
。
但无论如何,我们遇到了 LTO 相关的问题:codegen_module_perform_lto
占用了总时间的约 80%。
该谈谈 LTO 了
Rust 编译器将 crates 分割为“codegen units”,将每个 codegen unit 作为单独的模块交给 LLVM 进行编译。一般来说,优化是在每个 codegen unit 内进行的,然后在最后将它们链接在一起。
LTO 控制 LLVM 在链接期间进行的一系列优化,例如,跨代码生成单元的内联或优化。
Cargo(通过 rustc
) 提供了一些 LTO 选项:
- 关闭 — 禁用所有 LTO
- “瘦” LTO — 从理论上讲,与“胖” LTO 具有相似的性能优势,但运行成本较低
- “胖” LTO — 同时对所有 crates 进行最大程度的 LTO
如果未指定 LTO 选项,rustc
将使用“thin local LTO”,将“thin” LTO 限制为一次只对一个 crate 进行。
当前设置是什么
原来几年前,我在 Cargo.toml
中设置了 lto = “thin”
:
[profile.release]
lto = "thin"
debug = "full"
此外,debug = “full”
可以启用所有调试符号(在 release
配置文件中,这些符号通常会默认被排除)。也许我们也应该看看这个。
调整(常规)设置
让我们看看不同 lto
和 debug
设置下的编译时间和二进制文件大小(使用 cargo build --timings
像之前一样,以获得更精确的计时)。
Time / Size | debug=none |
debug=line-tables-only |
debug=limited |
debug=full |
---|---|---|---|---|
LTO disabled | 50.0s / 21.0Mi | 54.4s / 85.9Mi | 54.8s / 105.9Mi | 67.6s / 214.3Mi |
Thin local LTO | 67.5s / 20.1Mi | 71.5s / 95.4Mi | 73.6s / 117.0Mi | 88.2s / 256.8Mi |
“Thin” LTO | 133.7s / 20.3Mi | 141.7s / 80.6Mi | 140.7s / 96.0Mi | 172.2s / 197.5Mi |
“Fat” LTO | 189.1s / 15.9Mi | 211.1s / 64.4Mi | 212.5s / 75.8Mi | 287.1s / 155.9Mi |
从整体来看:这里最严重的情况是完整的调试符号会使编译时间增加30-50%,而“胖”LTO的编译时间大约是完全禁用LTO时的4倍。
这与文档中的预期基本一致——没错,胖LTO确实会耗时更长。但即使我们禁用所有优化,最终二进制文件的编译时间仍然需要50秒!
简要说明:50秒其实是可以接受的!
看看,50秒已经是巨大的改进——如果需要禁用LTO和调试符号……我的网站几乎没有负载。这完全没问题。甚至完全可持续!
在这里继续深入没有实际意义。
但就这样放着岂不是太无聊了?我们应该能做得更好,对吧?
另一个简要说明:我们不能使用增量编译吗?
这稍微复杂一些,但绝对可以——至少在本地开发时。一致加载构建缓存并不简单,但你需要在 Dockerfile 中通过 “缓存挂载” 使 /target
目录可访问,并在构建之间保持该目标目录的持久性。
不过,我认为 docker build
每次都能拥有一个干净的环境,而且我认为通过 Docker 的缓存系统进行操作是值得的——这就是我一开始使用 cargo-chef
的原因。
深入探讨:LLVM_module_optimize
也是如此?
即使禁用 LTO 和调试符号,编译最终二进制文件仍需 50 秒完成……某种操作。
让我们重新运行自我分析,看看发生了什么。
其中约70%是LLVM_module_optimize
——即LLVM正在优化代码的部分。在深入研究LLVM本身之前,让我们先看看是否有更简单的调优选项可以调整。
优化调优
release
配置文件默认使用 opt-level = 3
——也许如果我们降低优化级别,就能减少在此上的时间消耗。
我们实际上可以做得更好——由于我们的依赖项已被缓存,且我们只关心最终二进制文件,我们只需对最终二进制文件降低优化级别即可获得大部分优化收益:
[profile.release]
lto = "off"
debug = "none"
opt-level = 0 # Disable optimizations on the final binary
# ... But use a higher opt-level for all dependencies
# See here for more:
# https://doc.rust-lang.org/cargo/reference/profiles.html#overrides
[profile.release.package."*"]
opt-level = 3
与之前的选项一样,我们也可以从一些 opt-level
s 中进行选择:
0
禁用优化1
、2
和3
启用不同级别的优化“s”
和“z”
是优先考虑二进制文件大小的不同选项
再次尝试几种组合:
Final / Deps | deps: opt-level=3 |
deps: opt-level="s" |
deps: opt-level="z" |
---|---|---|---|
final: opt-level=0 |
14.7s / 26.0Mi | 15.0s / 25.9Mi | 15.7s / 26.3Mi |
final: opt-level=1 |
48.8s / 21.5Mi | 47.6s / 20.1Mi | 47.8s / 20.6Mi |
final: opt-level=2 |
50.8s / 20.9Mi | 55.2s / 20.2Mi | 55.4s / 20.7Mi |
final: opt-level=3 |
51.0s / 21.0Mi | 55.4s / 20.3Mi | 55.2s / 20.8Mi |
final: opt-level="s" |
46.0s / 20.1Mi | 45.7s / 18.9Mi | 46.0s / 19.3Mi |
final: opt-level="z" |
42.7s / 20.1Mi | 41.8s / 18.8Mi | 41.8s / 19.3Mi |
基本上:
- 任何级别优化后的最终二进制文件的基线时间约为50秒
- 如果禁用所有优化,则速度会快得多:仅需约15秒
LLVM 的优化机制是什么?
Rust 非常依赖优化,虽然对最终二进制文件全面禁用优化可能没问题,但如果至少能保留一些优化,那就更好了!
那么,让我们来看看是什么原因导致时间如此之长。rustc
的自我分析并没有给我们提供更多细节,因此我们必须从 LLVM 获得这些细节。
这里还有另外几个有用的 rustc
标志:
-Z time-llvm-passes
– 以纯文本形式输出 LLVM 性能分析信息-Z llvm-time-trace
– 以 Chrome 跟踪格式输出 LLVM 性能分析信息(同样使用该格式!)
使用 rustc
进行 LLVM 性能分析 — 纯文本
与之前一样,我们暂时跳过 Chrome 跟踪格式,看看纯文本能提供哪些信息。
# cargo chef:
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Ztime-llvm-passes' cargo chef cook --release ...
# final build:
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Ztime-llvm-passes' cargo build --release ...
遗憾的是,如果你再次尝试 docker build
,你会立即遇到类似以下情况:
[output clipped, log limit 2MiB reached]
这是因为 BuildKit(如果你在 Linux 上使用的是较新版本的 Docker)默认输出限制非常小。
我们可以直接提高这些限制,对吧?
这些限制由环境变量 BUILDKIT_STEP_LOG_MAX_SIZE
和 BUILDKTI_STEP_LOG_MAX_SPEED
配置。但如果我们通过类似以下方式将它们传递给 docker build
:
BUILDKIT_STEP_LOG_MAX_SIZE=-1 BUILDKTI_STEP_LOG_MAX_SPEED=-1 docker build ...
… 这样并不会生效,因为配置必须在 Docker 守护进程(daemon)上设置。
在大多数 Linux 发行版中,dockerd
作为 systemd
单元运行。
那直接在 systemd
单元上设置不就行了?
正确的做法是创建一个覆盖文件,例如:
$ systemctl edit --drop-in=buildkit-env.conf docker.service
(注:使用 --drop-in
参数可将文件命名为更具描述性的名称,而非 override.conf
)
这将打开一个新文件,其中我们可以设置环境覆盖项:
[Service]
Environment="BUILDKIT_STEP_LOG_MAX_SIZE=-1"
Environment="BUILDKIT_STEP_LOG_MAX_SPEED=-1"
设置完成后:
$ systemctl restart docker.service
验证配置…
重启后,可通过以下方式验证环境变量:
$ pgrep dockerd
1234567
$ cat /proc/1234567/environ | tr '' 'n' | grep -i 'buildkit'
BUILDKIT_STEP_LOG_MAX_SIZE=-1
BUILDKIT_STEP_LOG_MAX_SPEED=-1
(注:需要使用 tr
命令,因为环境变量是一个以空字符分隔的字符串,逐行搜索更方便)
因此,在终端上获得无限的 docker build
输出后,其中包含什么?约 200,000 行纯文本——这可能不是您希望从终端复制的内容。
因此,我们将输出重定向到 Docker 内的文件并像之前一样复制出来,会得到一堆通过/分析时序报告。它们各自看起来类似于:
===-------------------------------------------------------------------------===
Pass execution timing report
===-------------------------------------------------------------------------===
Total Execution Time: 0.0428 seconds (0.0433 wall clock)
---User Time--- --System Time-- --User+System-- ---Wall Time--- — Name ---
0.0072 ( 19.2%) 0.0015 ( 27.4%) 0.0086 ( 20.2%) 0.0087 ( 20.0%) InstCombinePass
0.0040 ( 10.8%) 0.0006 ( 10.8%) 0.0046 ( 10.8%) 0.0047 ( 10.8%) InlinerPass
0.0024 ( 6.4%) 0.0010 ( 18.0%) 0.0034 ( 7.9%) 0.0034 ( 7.8%) SimplifyCFGPass
0.0022 ( 5.9%) 0.0002 ( 4.5%) 0.0025 ( 5.7%) 0.0024 ( 5.6%) EarlyCSEPass
0.0021 ( 5.5%) 0.0001 ( 1.5%) 0.0021 ( 5.0%) 0.0022 ( 5.0%) GVNPass
0.0015 ( 4.0%) 0.0001 ( 2.2%) 0.0016 ( 3.8%) 0.0018 ( 4.2%) ArgumentPromotionPass
... entries here continue, and more passes below, for hundreds of thousands of lines ...
当然,解析和分析这些数据是可能的!但当每个通过执行单独输出且多线程可能干扰时序时,很难确定你正在查看的内容。
让我们看看是否有更好的方法来获取优质数据。
使用 rustc
进行 LLVM 性能分析 — 这次是实际跟踪
我们之前跳过了 -Z llvm-time-trace
选项,因为它会输出 Chrome 跟踪格式。
让我们重新审视一下:
# cargo chef:
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Zllvm-time-trace' cargo chef cook --release ...
# final build:
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Zllvm-time-trace' cargo build --release ...
它会生成一系列 $package-$hash.llvm_timings.json
文件,同时生成正常的编译 artifacts:
$ ls -lAh target/x86_64-unknown-linux-musl/release/deps | head
total 5G
-rw-r--r-- 1 root root 11.8K Jun 9 23:11 aho_corasick-ff268aeac1b7a243.d
-rw-r--r-- 1 root root 69.4M Jun 9 23:11 aho_corasick-ff268aeac1b7a243.llvm_timings.json
-rw-r--r-- 1 root root 6.6K Jun 9 23:11 allocator_api2-28ed2e0fa8ab7b44.d
-rw-r--r-- 1 root root 373.1K Jun 9 23:11 allocator_api2-28ed2e0fa8ab7b44.llvm_timings.json
-rw-r--r-- 1 root root 4.0K Jun 9 23:11 anstream-cf9519a72988d4c1.d
-rw-r--r-- 1 root root 4.4M Jun 9 23:11 anstream-cf9519a72988d4c1.llvm_timings.json
-rw-r--r-- 1 root root 2.4K Jun 9 23:11 anstyle-76a77f68346b4238.d
-rw-r--r-- 1 root root 885.3K Jun 9 23:11 anstyle-76a77f68346b4238.llvm_timings.json
-rw-r--r-- 1 root root 2.2K Jun 9 23:11 anstyle_parse-702e2f8f76fe1827.d
(为什么是 root
?几年前我尝试设置无根 Docker 时未成功,此后便未再尝试)
因此,在 cargo-chef
和最终构建之间删除 *.llvm_timings.json
,我们可以将最终二进制的单一配置文件提取到 web_http_server.llvm_timings.json
中。
这里有一个小问题:
$ du -sh web_http_server.llvm_timings.json
1.4G web_http_server.llvm_timings.json
它非常庞大。而且它只是一行!
不过,理论上,各种工具都应该能够处理这个文件:
Firefox 性能分析
我使用的是 Firefox,所以为什么不试试 Firefox Profiler 呢?它应该能够处理这个文件:
Firefox Profiler 还可以导入其他性能分析工具生成的配置文件,例如 Linux perf、Android SimplePerf、Chrome性能面板、Android Studio或任何使用dhat格式 或 Google 的 Trace Event Format 格式。
不幸的是,这没有成功:
查看网页控制台,我们可以看出失败的原因——内存不足:
perfetto.dev 在 Firefox 上的运行情况
当我搜索如何显示这些 Chrome 跟踪格式跟踪时,perfetto.dev 是另一个出现的替代方案。它同样由谷歌维护。
当我第一次尝试时,我使用了一个来自更长编译过程的较大跟踪,它也因内存不足而失败:
我不得不本地运行WASM处理器,遇到了影响Firefox的这个漏洞。
当时我放弃了并改用Chromium,但在撰写这篇博文的过程中,我再次尝试。较小的跟踪信息使其能够正常工作:
无论如何,我发现自己对这个接口的用法一无所知——而从LLVM加载复杂的跟踪数据可能也不是最好的入门点。
chrome://tracing
在Chromium上
你可能会认为这个选项是所有选项中效果最好的,但不幸的是它也失败了——尽管比其他选项更具趣味性:
这些选项对我来说都没有用——但这是一个格式已知的 JSON 文件,难道这么难吗?
结果发现,一个1.4GiB的单行JSON文件会让所有常规工具抱怨:
- 如果你尝试用
less
查看它,滚动会阻塞整个文件的处理 - 如果你尝试用
jq
处理它,它必须将整个1.4GiB加载到jq
的内部格式中(这显然会占用比原始1.4GiB多得多的空间) - Vim 在打开它时会卡住
- 你可能也不想直接将它
cat
到终端——毕竟它是 1.4GiB!
因此,我们可以只查看文件开头和结尾的几百个字符:
$ head -c300 web_http_server.llvm_timings.json
{"traceEvents":[{"pid":25,"tid":30,"ts":8291351,"ph":"X","dur":6827,"name":"RunPass","args":{"detail":"Expand large div/rem"}},{"pid":25,"tid":30,"ts":8298181,"ph":"X","dur":2,"name":"RunPass","args":{"detail":"Expand large fp convert"}},{"pid":25,"tid":30,"ts":8298183,"ph":"X","dur":8,"name":"RunPa
$ tail -c300 web_http_server.llvm_timings.json
me":""}},{"cat":"","pid":25,"tid":43,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}},{"cat":"","pid":25,"tid":44,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}},{"cat":"","pid":25,"tid":29,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}],"beginningOfTime":1749510885820760}
与 “JSON 对象格式” 匹配进行匹配,似乎我们有一个单一的 JSON 对象,如:
{
"traceEvents": [
{"pid":25,"tid":30,"ts":8291351,"ph":"X","dur":6827,"name":"RunPass","args":{"detail":"Expand large div/rem"}},
{"pid":25,"tid":30,"ts":8298181,"ph":"X","dur":2,"name":"RunPass","args":{"detail":"Expand large fp convert"}},
...
],
"beginningOfTime": 1749510885820760
}
如果我们将每个事件拆分为独立的对象,就可以使用常规工具进行处理。这可能类似于:
cat web_http_server.llvm_timings.json
| sed -E 's/},/}n/g;s/^{"traceEvents":[//g;s/],"beginningOfTime":[0-9]+}$//g'
> web-http-server.llvm_timings.jsonl
(即:将 },
转换为换行符,去除对象开头,去除对象结尾)
现在我们可以处理这个对象了。
$ wc -l web_http_server.llvm_timings.jsonl
7301865 web_http_server.llvm_timings.jsonl
$ head web_http_server.llvm_timings.jsonl
{"pid":25,"tid":30,"ts":8291351,"ph":"X","dur":6827,"name":"RunPass","args":{"detail":"Expand large div/rem"}}
{"pid":25,"tid":30,"ts":8298181,"ph":"X","dur":2,"name":"RunPass","args":{"detail":"Expand large fp convert"}}
{"pid":25,"tid":30,"ts":8298183,"ph":"X","dur":8,"name":"RunPass","args":{"detail":"Expand Atomic instructions"}}
{"pid":25,"tid":30,"ts":8298192,"ph":"X","dur":0,"name":"RunPass","args":{"detail":"Lower AMX intrinsics"}}
{"pid":25,"tid":30,"ts":8298193,"ph":"X","dur":0,"name":"RunPass","args":{"detail":"Lower AMX type for load/store"}}
{"pid":25,"tid":30,"ts":8298195,"ph":"X","dur":1,"name":"RunPass","args":{"detail":"Lower Garbage Collection Instructions"}}
{"pid":25,"tid":30,"ts":8298196,"ph":"X","dur":1,"name":"RunPass","args":{"detail":"Shadow Stack GC Lowering"}}
{"pid":25,"tid":30,"ts":8298197,"ph":"X","dur":1164,"name":"RunPass","args":{"detail":"Remove unreachable blocks from the CFG"}}
{"pid":25,"tid":30,"ts":8299362,"ph":"X","dur":1,"name":"RunPass","args":{"detail":"Instrument function entry/exit with calls to e.g. mcount() (post inlining)"}}
{"pid":25,"tid":30,"ts":8299363,"ph":"X","dur":5,"name":"RunPass","args":{"detail":"Scalarize Masked Memory Intrinsics"}}
LLVM 跟踪事件中包含什么?
这些事件都包含 “ph”:“X”
。
根据规范,ph
字段表示事件类型,而 X
表示“完整”事件,记录特定任务在给定线程(tid
)上耗费的时间。持续时间以微秒为单位由 dur
字段给出。
除此之外,我们还有 M
类型的事件:
$ cat web_http_server.llvm_timings.jsonl | jq -c 'select(.ph != "X")' | head
{"cat":"","pid":25,"tid":27,"ts":0,"ph":"M","name":"process_name","args":{"name":"rustc"}}
{"cat":"","pid":25,"tid":27,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":30,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":35,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":32,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":33,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":34,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":39,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":40,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
{"cat":"","pid":25,"tid":36,"ts":0,"ph":"M","name":"thread_name","args":{"name":""}}
这些是“元数据”事件——在我们的案例中,没有太多有用信息。
除此之外,就没有其他内容了:
$ cat web_http_server.llvm_timings.jsonl | jq -c 'select(.ph != "X" and .ph != "M")'
<nothing>
回到那些 X
事件——其中有很多 “name”:“RunPass”
的事件。还有什么其他信息?
$ cat web_http_server.llvm_timings.jsonl | jq -c 'select(.ph == "X" and .name != "RunPass")' | head
{"pid":25,"tid":30,"ts":8291349,"ph":"X","dur":32009,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc12___rust_alloc"}}
{"pid":25,"tid":30,"ts":8323394,"ph":"X","dur":283,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc14___rust_dealloc"}}
{"pid":25,"tid":30,"ts":8323678,"ph":"X","dur":216,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc14___rust_realloc"}}
{"pid":25,"tid":30,"ts":8323895,"ph":"X","dur":179,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc19___rust_alloc_zeroed"}}
{"pid":25,"tid":30,"ts":8324075,"ph":"X","dur":155,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc26___rust_alloc_error_handler"}}
{"pid":25,"tid":30,"ts":8288691,"ph":"X","dur":35693,"name":"OptModule","args":{"detail":"5z12fn0vr5uv0i2pfsngwe5em"}}
{"pid":25,"tid":35,"ts":9730144,"ph":"X","dur":16,"name":"Annotation2MetadataPass","args":{"detail":"[module]"}}
{"pid":25,"tid":35,"ts":9730214,"ph":"X","dur":10,"name":"ForceFunctionAttrsPass","args":{"detail":"[module]"}}
{"pid":25,"tid":35,"ts":9730346,"ph":"X","dur":11,"name":"InnerAnalysisManagerProxy<llvm::AnalysisManager<llvm::Function>, llvm::Module>","args":{"detail":"[module]"}}
{"pid":25,"tid":35,"ts":9730416,"ph":"X","dur":17,"name":"TargetLibraryAnalysis","args":{"detail":"llvm.expect.i1"}}
不错!看起来我们可以解析一些符号以获取单个函数的执行时间。
如果我们跟踪正在运行的内容及其耗时,应该能更好地理解为什么编译时间如此之长。
之后,某些类型的事件会有汇总信息,比如Total OptFunction
。这些相当于该事件类型(在本例中为OptFunction
)的持续时间之和。让我们看看哪些操作耗时最长:
$ cat web_http_server.llvm_timings.jsonl | jq -r 'select(.name | startswith("Total ")) | "(.dur / 1e6) (.name)"' | sort -rn | head
665.369662 Total ModuleInlinerWrapperPass
656.465446 Total ModuleToPostOrderCGSCCPassAdaptor
632.441396 Total DevirtSCCRepeatedPass
627.236893 Total PassManager<llvm::LazyCallGraph::SCC, llvm::AnalysisManager<llvm::LazyCallGraph::SCC, llvm::LazyCallGraph&>, llvm::LazyCallGraph&, llvm::CGSCCUpdateResult&>
536.738589 Total PassManager<llvm::Function>
372.768547 Total CGSCCToFunctionPassAdaptor
193.914869 Total ModuleToFunctionPassAdaptor
190.924012 Total OptModule
189.621119 Total OptFunction
182.250077 Total InlinerPass
此次运行在 16 核机器上耗时约 110 秒,显然某些优化阶段被重复计数(这合乎逻辑——我们同时看到了 ModuleInlinerWrapperPass
和 InlinerPass
,且 OptModule
似乎只是调用了 OptFunction
)。
但总体而言,优化(OptFunction
)和内联(InlinerPass
)是耗时最多的两个部分——让我们看看是否能对此做些改进。
能否让 InlinerPass
运行得更快?
希望可以!
LLVM 提供了一系列可配置的参数,rustc
通过 -C llvm-args
标志暴露这些参数。截至撰写本文时(2025 年 6 月),与内联相关的选项大约有 ~100 个(通过 rustc -C llvm-args=‘--help-list-hidden’
获取)。其中,控制成本分析的文件中包含了大量相关选项 位于控制成本分析的文件中。
坦白说,我对 LLVM 的内联优化了解非常有限。大多数选项都与内联操作的“成本”相关,或是与被内联的函数本身相关等。我在这方面基本上是凭直觉操作。但有几个参数似乎是不错的调优候选项:
--inlinedefault-threshold=225
— “默认内联执行程度”--inline-threshold=225
— “控制内联执行程度”--inlinehint-threshold=325
— “带内联提示函数的内联阈值”
对于所有这些参数,“阈值”大致意味着“允许内联成本低于阈值的函数”,因此更高的阈值意味着更多的内联操作。
如果我们将所有这些参数设置为某个值(例如 50
),我们应该会发现内联操作减少,从而编译时间更快。
例如:
RUSTFLAGS="-Cllvm-args=-inline-threshold=50 -Cllvm-args=-inlinedefault-threshold=50 -Cllvm-args=-inlinehint-threshold=50" ...
(为什么要把 -C llvm-args
分开?我找不到通过 RUSTFLAGS
环境变量来处理空格的方法——也许在 .cargo/config.toml
中设置 build.rustflags
就能实现,但这个解决方案有效 🤷)
无论如何,将阈值降至 50 确实会更快!大约 42.2 秒,从 48.8 秒减少。
以下是几个值的示例:
(注:最小值是 1,而不是 0。为什么是 1?有时 0 有特殊行为——设置为 1 似乎更安全。)
在这些值中,很难确切地说出最佳值是什么,但对于我的用例(记住:我的网站几乎没有负载!),将阈值设置为10看起来很有希望。不过,我们暂时先不这样做。
我们能让OptFunction
更快吗?
优化函数是另一个耗时的任务。
这里的调整选项对我来说并不清晰(我们已经将 opt-level
设置为 1,而 opt-level = 0
会完全禁用优化)。因此,让我们看看究竟是什么导致了如此长的执行时间。
首先,简要查看事件格式:
$ cat web_http_server.llvm_timings.jsonl | jq -c 'select(.name == "OptFunction")' | head
{"pid":25,"tid":30,"ts":7995006,"ph":"X","dur":32052,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc12___rust_alloc"}}
{"pid":25,"tid":30,"ts":8027059,"ph":"X","dur":242,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc14___rust_dealloc"}}
{"pid":25,"tid":30,"ts":8027302,"ph":"X","dur":158,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc14___rust_realloc"}}
{"pid":25,"tid":30,"ts":8027461,"ph":"X","dur":126,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc19___rust_alloc_zeroed"}}
{"pid":25,"tid":30,"ts":8027589,"ph":"X","dur":150,"name":"OptFunction","args":{"detail":"_RNvCscSpY9Juk0HT_7___rustc26___rust_alloc_error_handler"}}
{"pid":25,"tid":35,"ts":31457262,"ph":"X","dur":24576,"name":"OptFunction","args":{"detail":"_ZN10serde_json5value8to_value17h0315c73febebe85cE"}}
{"pid":25,"tid":35,"ts":31481850,"ph":"X","dur":11862,"name":"OptFunction","args":{"detail":"_ZN10serde_json5value8to_value17h0516143613516496E"}}
{"pid":25,"tid":35,"ts":31493764,"ph":"X","dur":15830,"name":"OptFunction","args":{"detail":"_ZN10serde_json5value8to_value17h0bdb4ac12d8ad59bE"}}
{"pid":25,"tid":35,"ts":31509615,"ph":"X","dur":8221,"name":"OptFunction","args":{"detail":"_ZN10serde_json5value8to_value17h0c630b789ee318c2E"}}
{"pid":25,"tid":35,"ts":31517858,"ph":"X","dur":8670,"name":"OptFunction","args":{"detail":"_ZN10serde_json5value8to_value17h12ba815471bb2bc8E"}}
在原始形式中,每个事件的 .args.detail
字段都包含被优化的函数的混淆符号。我们可以使用 rustfilt
将它们“解混淆”回原始的 Rust 符号,例如:
$ cargo install rustfilt
$ rustfilt '_RNvCscSpY9Juk0HT_7___rustc12___rust_alloc'
__rustc::__rust_alloc
$ rustfilt '_ZN10serde_json5value8to_value17h0315c73febebe85cE'
serde_json::value::to_value
值得注意的是,在上面的列表中,尽管有多个 serde_json::value::to_value
项,但它们实际上具有不同的哈希值:
$ rustfilt -h '_ZN10serde_json5value8to_value17h0315c73febebe85cE'
serde_json::value::to_value::h0315c73febebe85c
$ rustfilt -h '_ZN10serde_json5value8to_value17h0516143613516496E'
serde_json::value::to_value::h0516143613516496
$ rustfilt -h '_ZN10serde_json5value8to_value17h0bdb4ac12d8ad59bE'
serde_json::value::to_value::h0bdb4ac12d8ad59b
$ rustfilt -h '_ZN10serde_json5value8to_value17h0c630b789ee318c2E'
serde_json::value::to_value::h0c630b789ee318c2
$ rustfilt -h '_ZN10serde_json5value8to_value17h12ba815471bb2bc8E'
serde_json::value::to_value::h12ba815471bb2bc8
… 考虑到 serde_json::value::to_value
是一个泛型函数,这很合理——它可能使用不同的泛型参数(“单态化”)进行了优化。
等等,为什么我们要优化其他 crates 中的函数?
简短的回答是,优化是在函数被单态化的 crates 环境中进行的。因此,如果我们定义了一个类型 Foo
,然后调用 Option<Foo>
上的方法,那么这些类型的方法首先会存在于我们的 crates 环境中——这意味着它们会以与我们的 crates 相同的配置进行编译和优化。
了解编译器底层工作原理后,这应该能说得通——但从外部来看,确实有点奇怪!
到底是什么耗时这么久?
现在我们知道我们在看什么,我们可以开始进行一些分析。例如,通过找到我们花最多时间优化的单个函数:
$ cat web_http_server.llvm_timings.jsonl
| jq -c 'select(.name == "OptFunction")'
| jq -sc 'sort_by(-.dur) | .[] | { dur: (.dur / 1e6), detail: .args.detail }'
| head
{"dur":1.875744,"detail":"_ZN15web_http_server6photos11PhotosState3new28_$u7b$$u7b$closure$u7d$$u7d$17ha4de409b0951d78bE"}
{"dur":1.44252,"detail":"_ZN14tokio_postgres6client6Client5query28_$u7b$$u7b$closure$u7d$$u7d$17h18fb9179bb73bfa4E"}
{"dur":1.440186,"detail":"_ZN15web_http_server3run28_$u7b$$u7b$closure$u7d$$u7d$17h426fe76bd1b089abE"}
{"dur":1.397705,"detail":"_ZN15web_http_server6photos11PhotosState3new28_$u7b$$u7b$closure$u7d$$u7d$17ha4de409b0951d78bE"}
{"dur":1.170948,"detail":"_ZN14tokio_postgres11connect_raw11connect_raw28_$u7b$$u7b$closure$u7d$$u7d$17h0dfcfa0a648a93f8E"}
{"dur":1.158111,"detail":"_ZN14pulldown_cmark5parse15Parser$LT$F$GT$19handle_inline_pass117hc91a3dc90e0e9e0cE"}
{"dur":1.131707,"detail":"_ZN129_$LT$axum..boxed..MakeErasedHandler$LT$H$C$S$GT$$u20$as$u20$axum..boxed..ErasedIntoRoute$LT$S$C$core..convert..Infallible$GT$$GT$9clone_box17he7f38a2ccd053fbbE"}
{"dur":1.062162,"detail":"_ZN4core3ptr49drop_in_place$LT$http..extensions..Extensions$GT$17h89b138bb6c1aa101E"}
{"dur":1.026656,"detail":"_ZN15web_http_server3run28_$u7b$$u7b$closure$u7d$$u7d$17h426fe76bd1b089abE"}
{"dur":1.009844,"detail":"_ZN4core3ptr252drop_in_place$LT$$LT$alloc..vec..drain..Drain$LT$T$C$A$GT$$u20$as$u20$core..ops..drop..Drop$GT$..drop..DropGuard$LT$lol_html..selectors_vm..stack..StackItem$LT$lol_html..rewriter..rewrite_controller..ElementDescriptor$GT$$C$alloc..alloc..Global$GT$$GT$17h62ca0c07fce3ede0E"}
(为什么有两个独立的 jq
调用?如果只进行一次调用,-s
/--slurp
选项会将整个文件加载到一个数组中再进行处理,而这是我们试图避免的关键操作)
单个函数花费的时间出人意料地多!性能分析大致将总编译时间翻倍,但即使优化单个函数花费 1 秒也相当长!
但让我们进一步详细分析。我们有:
web_http_server::photos::PhotosState::new::{{closure}}
— 这是某个闭包,位于一个巨大的、400 行长的异步函数中,该函数负责为 https://sharnoff.io/photos 进行初始化web_http_server::run::{{closure}}
— 这是主入口点(也是异步的)中的闭包,但所有闭包都是小的错误处理,如.wrap_err_with(|| format!(“failed to bind address {addr:?}”))
- 这里可能有些奇怪的事情在发生!
… 以及一些也花了一段时间的依赖项:
pulldown_cmark
包含一个 500 行函数,该函数对回调函数泛型化tokio_postgres::connect_raw
是 合理大小的异步函数 中的简单闭包——这可能与我web_http_server::run
中的闭包出于相同原因?http::extensions::Extensions
看起来应该很简单(没有显式的析构函数),但内部实际上是Option<Box<HashMap<TypeId, Box<dyn ...>, BuildDefaultHasher<..>>>>
。也许这里内联化带来了很多复杂性?- 删除
vec::Drain<T>
以及一系列嵌套的lol_html
类型也会出现错误——也许是因为类似的原因
或者,我们可以按最外层的 crate 进行分解:
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"root":"$(rustfilt "$1" | sed -E "s/^([a-z_-]+)::.*/1/g")"}"'
| jq -s -r 'group_by(.root) | map({ root: .[0].root, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[] | "(.dur / 1e6) (.root)"'
| head
61.534452 core
13.750173 web_http_server
11.237289 tokio
7.890088 tokio_postgres
6.851621 lol_html
4.470053 alloc
4.177471 feed_rs
3.269217 std
3.067573 hashbrown
3.063146 eyre
当然,这并不是一个非常完美的衡量标准——最外层的 crate 不一定是最适合用来衡量编译时间的,而且还有许多像 <Foo as Bar>::baz
这样的项目无法通过这种简单的过滤来捕获。但抛开这些不谈,core
占如此大的比例还是令人感到惊讶!
进一步分析发现,其中84%的时间都花在了对core::ptr::drop_in_place
的参数化上!
深入探讨闭包,使用 v0 符号修饰
闭包的编译时间过长显得非常可疑——或许值得进一步调查。但有一个问题:所有符号都以 {{closure}}
结尾,却没有明确指出是哪一个闭包占用了大量时间。
事实证明,有一个简单的解决方案!截至 2025 年 6 月,rustc
目前默认使用“传统”符号混淆格式,但有一个更新的选项可以提供更多信息:v0 格式。
我们可以通过在现有标志中添加 RUSTFLAGS=“-C symbol-mangling-version=v0”
来启用它,现在标志看起来像这样:
RUSTC_BOOTSTRAP=1 RUSTFLAGS="-Csymbol-mangling-version=v0 -Zllvm-time-trace" cargo build --timings ...
(附注:该功能的 issue 已开放 6 年,为何尚未合并?原来,要在 gdb
和 perf
等常用工具中添加支持需要大量上游工作。其中大部分已完成,但尚未全部实现。)
此操作的最终结果是,LLVM 跟踪中生成的符号质量有了显著提升。例如,以下是 serde_json::value::to_value
符号的当前样式:
$ cat web_http_server.llvm_timings.jsonl | jq -c 'select(.name == "OptFunction")' | grep -E 'serde_json.+value.+to_value' | head
{"pid":25,"tid":35,"ts":34400185,"ph":"X","dur":7336,"name":"OptFunction","args":{"detail":"_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueINtNCNvCs5etrU9lJXb7_15web_http_server5index012IndexContextNtNtNtNtBQ_4blog6handle7context9RootIndexNtNtNtNtBQ_6photos6handle7context9RootIndexEEBQ_"}}
{"pid":25,"tid":35,"ts":34407530,"ph":"X","dur":13226,"name":"OptFunction","args":{"detail":"_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueNtNtNtNtCs5etrU9lJXb7_15web_http_server4blog6handle7context4PostEBR_"}}
{"pid":25,"tid":35,"ts":34420761,"ph":"X","dur":10344,"name":"OptFunction","args":{"detail":"_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueNtNtNtNtCs5etrU9lJXb7_15web_http_server4blog6handle7context5IndexEBR_"}}
{"pid":25,"tid":35,"ts":34431114,"ph":"X","dur":11100,"name":"OptFunction","args":{"detail":"_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueNtNtNtNtCs5etrU9lJXb7_15web_http_server6photos6handle7context11AlbumsIndexEBR_"}}
$ rustfilt '_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueINtNCNvCs5etrU9lJXb7_15web_http_server5index012IndexContextNtNtNtNtBQ_4blog6handle7context9RootIndexNtNtNtNtBQ_6photos6handle7context9RootIndexEEBQ_'
serde_json::value::to_value::<web_http_server::index::{closure#0}::IndexContext<web_http_server::blog::handle::context::RootIndex, web_http_server::photos::handle::context::RootIndex>>
$ rustfilt '_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueNtNtNtNtCs5etrU9lJXb7_15web_http_server4blog6handle7context4PostEBR_'
serde_json::value::to_value::<web_http_server::blog::handle::context::Post>
$ rustfilt '_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueNtNtNtNtCs5etrU9lJXb7_15web_http_server4blog6handle7context5IndexEBR_'
serde_json::value::to_value::<web_http_server::blog::handle::context::Index>
$ rustfilt '_RINvNtCs9KWWFfvvCPd_10serde_json5value8to_valueNtNtNtNtCs5etrU9lJXb7_15web_http_server6photos6handle7context11AlbumsIndexEBR_'
serde_json::value::to_value::<web_http_server::photos::handle::context::AlbumsIndex>
不仅闭包标记得到了改善(例如 {closure#0}
),而且所有内容都实现了完整的泛型支持!
现在,究竟是什么导致进展缓慢的原因应该更加清晰:
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sr 'sort_by(-.dur) | .[] | "(.dur / 1e4 | round | . / 1e2)s (.fn)"'
| head -n5
1.99s <web_http_server::photos::PhotosState>::new::{closure#0}
1.56s web_http_server::run::{closure#0}
1.41s <web_http_server::photos::PhotosState>::new::{closure#0}
1.22s core::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>
1.15s core::ptr::drop_in_place::<axum::routing::method_routing::MethodEndpoint<web_http_server::AppState, core::convert::Infallible>>
但前几个闭包非常小:
let is_jpg = |path: &Path| path.extension().and_then(|s| s.to_str()) == Some("jpg");
和
let app = axum::Router::new()
/* .route(...) for many others */
.route("/feed.xml", axum::routing::get(move || async move { feed }))
// this one: ^^^^^^^^^^^^^^^^^^^^^^^^^^^
如果我们移除这些闭包,用单独定义的函数替换它们(在可能的情况下),LLVM 仍然报告优化外层函数中的 {closure#0}
需要很长时间。
这些闭包是从哪里来的?
在使用 RUSTFLAGS=“--emit=llvm-ir”
导出 LLVM IR(将其放置在 target/.../deps/*.ll
中)并搜索生成的函数后,我发现了一行类似于:
; core::ptr::drop_in_place::<<web_http_server::photos::PhotosState>::new::{closure#0}::process_photo::{closure#0}>
该 process_photo
函数是一个嵌套的异步函数,直接定义在 PhotosState::new
函数内部——那么为什么符号显示它是在闭包内部定义的?
这是因为 rustc
内部将异步函数/块表示为嵌套闭包。因此,所有这些编译 closure#0
需要很长时间的异步函数实际上只是引用了函数本身!
通过在 github 上进行快速搜索(is:issue state:open async fn closure mangle
),我发现已经有人提出了 关于此问题的公开问题!
大型异步函数是否有害?
回到我们之前的列表——那些 LLVM 优化 closure#0
耗时较长的异步函数,实际上只是在函数本体上花费了大量时间。大型函数难以优化是合乎逻辑的,而异步函数更是如此。
识别主 crate 中所有耗时较长的函数非常简单:
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq -r 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server")) | "(.dur / 1e4 | round | . / 1e2)s (.fn)"'
| head -n10
4.11s <web_http_server::photos::PhotosState>::new::{closure#0}
3.05s web_http_server::run::{closure#0}
1.44s core::ptr::drop_in_place::<web_http_server::run::{closure#0}>
0.6s <web_http_server::reading_list::handle::post_login as axum::handler::Handler<(axum_core::extract::private::ViaRequest, axum::extract::state::State<&web_http_server::reading_list::ReadingListState>, axum::extract::state::State<&tera::tera::Tera>, axum_extra::extract::cookie::CookieJar, axum::form::Form<web_http_server::reading_list::handle::LoginForm>), web_http_server::AppState>>::call::{closure#0}
0.57s web_http_server::reading_list::fetch_posts_data::{closure#0}
0.51s <web_http_server::reading_list::ReadingListState>::make_pool::{closure#0}
0.44s <web_http_server::reading_list::ReadingListState>::refresh_single::{closure#0}
0.38s <web_http_server::photos::PhotosState>::process_photo::{closure#0}
0.38s <web_http_server::html::WriteState>::process_event
0.33s core::ptr::drop_in_place::<<web_http_server::reading_list::ReadingListState>::run_refresh::{closure#0}::{closure#0}>
这里一些最耗时的函数与设置有关。
让我们尝试将一个函数拆分,看看是否有效。我们从 PhotosState::new
开始。
在进行任何更改之前:PhotosState::new
的完整计时结果
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq 'select(.fn | test("^(core::ptr::drop_in_place::<)?<web_http_server::photos::PhotosState>::new")) | .dur'
| jq -sr 'add | . / 1e4 | round | . / 1e2 | "(.)s"'
5.3s
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq -r 'select(.fn | test("^(core::ptr::drop_in_place::<)?<web_http_server::photos::PhotosState>::new")) | "(.dur / 1e4 | round | . / 1e2)s (.fn)"'
4.11s <web_http_server::photos::PhotosState>::new::{closure#0}
0.27s core::ptr::drop_in_place::<<web_http_server::photos::PhotosState>::new::{closure#0}>
0.24s core::ptr::drop_in_place::<<web_http_server::photos::PhotosState>::new::{closure#0}::{closure#2}::{closure#0}>
0.23s core::ptr::drop_in_place::<<web_http_server::photos::PhotosState>::new::{closure#0}::{closure#2}>
0.19s core::ptr::drop_in_place::<<web_http_server::photos::PhotosState>::new::{closure#0}::{closure#6}::{closure#0}>
0.11s core::ptr::drop_in_place::<<web_http_server::photos::PhotosState>::new::{closure#0}::{closure#7}::{closure#0}>
0.03s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#6}::{closure#0}
0.02s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#3}
0.02s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#11}
0.02s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#4}
0.02s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#5}
0.01s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#2}
0.01s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#7}::{closure#0}
0.01s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#2}::{closure#0}
0.01s <web_http_server::photos::PhotosState>::new::{closure#0}::{closure#1}::{closure#1}
在第一次尝试中,我尝试在保持 .await
数量不变的情况下拆分它——这两者很容易意外发生,这样做有望孤立导致问题的复杂性类型。
在简单拆分后:photos::init
的完整计时结果
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server::photos::(init|PhotosState>::new)")) | .dur'
| jq -sr 'add | . / 1e4 | round | . / 1e2 | "(.)s"'
4.66s
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq -r 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server::photos::(init|PhotosState>::new)")) | "(.dur / 1e4 | round | . / 1e2)s (.fn)"'
3.37s web_http_server::photos::init::make_state::{closure#0}
0.24s core::ptr::drop_in_place::<web_http_server::photos::init::image_process_futs::{closure#0}::{closure#0}>
0.21s core::ptr::drop_in_place::<web_http_server::photos::init::album_process_futs::{closure#0}>
0.21s core::ptr::drop_in_place::<web_http_server::photos::init::make_state::{closure#0}>
0.16s core::ptr::drop_in_place::<web_http_server::photos::init::image_process_futs::{closure#0}>
0.12s core::ptr::drop_in_place::<web_http_server::photos::init::album_process_futs::{closure#1}>
0.06s web_http_server::photos::init::album_process_futs::{closure#0}
0.04s web_http_server::photos::init::image_process_futs::{closure#0}
0.03s web_http_server::photos::init::album_process_futs::{closure#1}
0.03s web_http_server::photos::init::album_process_futs
0.02s core::ptr::drop_in_place::<web_http_server::photos::init::get_img_candidates::{closure#0}>
0.02s web_http_server::photos::init::make_album_membership
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#1}
0.02s web_http_server::photos::init::make_albums_in_order
0.02s web_http_server::photos::init::image_process_futs
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#3}
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#2}
0.02s web_http_server::photos::init::image_process_futs::{closure#0}::{closure#0}
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#7}
0.01s web_http_server::photos::init::make_all_album
0.01s web_http_server::photos::init::make_recently_published_albums
0.01s web_http_server::photos::init::make_images_by_time
0s web_http_server::photos::init::get_img_candidates::{closure#0}::{closure#1}::{closure#1}
有趣的是,这并没有带来太大改善:总时间仅从5.3秒减少到4.7秒。
因此,我进一步尝试将几个相邻的.await
合并到各自的函数中——将总数从10个减少到3个。
合并.await
后的结果
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server::photos::(init|PhotosState>::new)")) | .dur'
| jq -sr 'add | . / 1e4 | round | . / 1e2 | "(.)s"'
6.24s
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq -r 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server::photos::(init|PhotosState>::new)")) | "(.dur / 1e4 | round | . / 1e2)s (.fn)"'
2.7s web_http_server::photos::init::process_all_images::{closure#0}
1.93s web_http_server::photos::init::make_state::{closure#0}
0.25s core::ptr::drop_in_place::<web_http_server::photos::init::image_process_futs::{closure#0}::{closure#0}>
0.25s core::ptr::drop_in_place::<web_http_server::photos::init::album_process_futs::{closure#0}>
0.18s core::ptr::drop_in_place::<web_http_server::photos::init::image_process_futs::{closure#0}>
0.14s core::ptr::drop_in_place::<web_http_server::photos::init::album_process_futs::{closure#1}>
0.09s core::ptr::drop_in_place::<web_http_server::photos::init::process_all_images::{closure#0}>
0.08s core::ptr::drop_in_place::<web_http_server::photos::init::join_image_futs<web_http_server::photos::init::image_process_futs::{closure#0}>::{closure#0}>
0.07s core::ptr::drop_in_place::<web_http_server::photos::init::make_state::{closure#0}>
0.07s web_http_server::photos::init::album_process_futs::{closure#0}
0.06s core::ptr::drop_in_place::<web_http_server::photos::init::parse::{closure#0}>
0.04s core::ptr::drop_in_place::<web_http_server::photos::init::join_album_futs::{closure#0}>
0.04s web_http_server::photos::init::image_process_futs::{closure#0}
0.03s web_http_server::photos::init::album_process_futs
0.03s web_http_server::photos::init::make_album_membership
0.03s core::ptr::drop_in_place::<web_http_server::photos::init::get_img_candidates::{closure#0}>
0.03s web_http_server::photos::init::album_process_futs::{closure#1}
0.03s web_http_server::photos::init::make_albums_in_order
0.03s web_http_server::photos::init::image_process_futs
0.02s web_http_server::photos::init::process_all_images::{closure#0}::{closure#1}
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#0}
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#1}
0.02s web_http_server::photos::init::process_all_images::{closure#0}::{closure#2}
0.02s web_http_server::photos::init::image_process_futs::{closure#0}::{closure#0}
0.02s web_http_server::photos::init::make_all_album
0.01s web_http_server::photos::init::make_images_by_time
0.01s web_http_server::photos::init::make_recently_published_albums
0s web_http_server::photos::init::get_img_candidates::{closure#0}::{closure#1}::{closure#1}
但这反而耗时更长!从4.66秒增加到6.24秒!
此时,似乎异步函数存在某种异常情况。否则,为何拆分更多函数会导致性能下降?
在幕后,异步函数被转换成一个复杂的状态机。那里可能发生了奇怪的事情,所以如果我们想在调用者那里简化它,我们可以将 Future
转换成一个 trait 对象,以掩盖其背后的实现(通常是 Pin<Box<dyn Future>>
)。
这次,让我们添加一个新函数,例如:
fn erase<'a, T>(
fut: impl 'a + Send + Future<Output = T>,
) -> Pin<Box<dyn 'a + Send + Future<Output = T>>> {
Box::pin(fut)
}
并在所有使用 .await
的地方使用它。例如:
// old:
let candidates = get_img_candidates().await?;
// new:
let candidates = erase(get_img_candidates()).await?;
最终更改:将未来转换为 Pin>
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server::photos::(init|PhotosState>::new)")) | .dur'
| jq -sr 'add | . / 1e4 | round | . / 1e2 | "(.)s"'
2.14s
$ cat web_http_server.llvm_timings.jsonl
| jq -r 'select(.name == "OptFunction") | "(.dur) (.args.detail)"'
| xargs -l bash -c 'echo "{"dur":$0,"fn":"$(rustfilt "$1")"}"'
| jq -sc 'group_by(.fn) | map({ fn: .[0].fn, dur: (map(.dur) | add) }) | sort_by(-.dur) | .[]'
| jq -r 'select(.fn | test("^(core::ptr::drop_in_place::<)?<*web_http_server::photos::(init|PhotosState>::new)")) | "(.dur / 1e4 | round | . / 1e2)s (.fn)"'
0.25s web_http_server::photos::init::process_all_images::{closure#0}
0.21s core::ptr::drop_in_place::<web_http_server::photos::init::image_process_futs::{closure#0}::{closure#0}>
0.2s core::ptr::drop_in_place::<web_http_server::photos::init::image_process_futs::{closure#0}>
0.2s web_http_server::photos::init::join_image_futs::<web_http_server::photos::init::image_process_futs::{closure#0}>::{closure#0}
0.19s core::ptr::drop_in_place::<web_http_server::photos::init::album_process_futs::{closure#0}>
0.13s web_http_server::photos::init::parse::{closure#0}
0.11s core::ptr::drop_in_place::<web_http_server::photos::init::album_process_futs::{closure#1}>
0.1s web_http_server::photos::init::get_img_candidates::{closure#0}
0.1s core::ptr::drop_in_place::<web_http_server::photos::init::make_state::{closure#0}>
0.06s core::ptr::drop_in_place::<web_http_server::photos::init::process_all_images::{closure#0}>
0.06s web_http_server::photos::init::album_process_futs::{closure#0}
0.06s web_http_server::photos::init::album_process_futs
0.05s web_http_server::photos::init::join_album_futs::{closure#0}
0.05s web_http_server::photos::init::make_albums_in_order
0.05s core::ptr::drop_in_place::<web_http_server::photos::init::join_image_futs<web_http_server::photos::init::image_process_futs::{closure#0}>::{closure#0}>
0.04s core::ptr::drop_in_place::<web_http_server::photos::init::parse::{closure#0}>
0.03s web_http_server::photos::init::image_process_futs::{closure#0}
0.03s web_http_server::photos::init::make_all_album
0.03s web_http_server::photos::init::album_process_futs::{closure#1}
0.02s core::ptr::drop_in_place::<web_http_server::photos::init::join_album_futs::{closure#0}>
0.02s core::ptr::drop_in_place::<web_http_server::photos::init::get_img_candidates::{closure#0}>
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#1}
0.02s web_http_server::photos::init::make_state::{closure#0}::{closure#0}
0.02s web_http_server::photos::init::make_recently_published_albums
0.02s web_http_server::photos::init::image_process_futs::{closure#0}::{closure#0}
0.01s web_http_server::photos::init::make_images_by_time
0.01s web_http_server::photos::init::erase::<core::result::Result<std::collections::hash::map::HashMap<alloc::string::String, &web_http_server::photos::Album>, eyre::Report>, web_http_server::photos::init::join_album_futs::{closure#0}>
0.01s web_http_server::photos::init::erase::<core::result::Result<web_http_server::photos::init::ProcessedImages, eyre::Report>, web_http_server::photos::init::process_all_images::{closure#0}>
0.01s web_http_server::photos::init::erase::<core::result::Result<(web_http_server::photos::MapSettings, web_http_server::photos::FlexGridSettings, web_http_server::photos::parsed::HiddenAlbumsAndPhotos, web_http_server::photos::parsed::Albums), eyre::Report>, web_http_server::photos::init::parse::{closure#0}>
0.01s web_http_server::photos::init::process_all_images::{closure#0}::{closure#1}
0.01s web_http_server::photos::init::process_all_images::{closure#0}::{closure#2}
0.01s web_http_server::photos::init::make_state
0.01s web_http_server::photos::init::get_img_candidates::{closure#0}::{closure#1}::{closure#1}
这次成功了——缩短至 2.14 秒。
因此,从 5.3 秒缩短至 2.14 秒——这是一个显著的改进,尽管为此付出了大量努力。(顺便说一句,当我用 Box::pin
包裹未来对象而非创建新函数时,这里并没有带来差异。)
重新运行构建过程而不进行性能分析,总耗时从48.8秒减少到46.8秒。虽然减少幅度不大,但这仅仅是通过优化一个函数实现的!
(附注:关于 #[inline(never)]
?我尝试了启用和禁用该选项——在进行类型转换后,禁用这些函数的内联编译并未改善编译时间,但它仍有助于确保 LLVM 计时中的更好归因。)
(附注:关于禁用 poll 函数的内联编译?我还尝试将异步函数包裹在一个带有 #[inline(never)]
标记的 poll
函数的 Future
实现中。这在一定程度上有所帮助,但效果不如包装好。)
整合方案
有多种方法可供选择——让我们尝试:
- 使用 LLVM args 减少内联;
- 将主 crate 中昂贵的函数拆分;以及
- 从依赖项中删除泛型,以避免在主 crate 中编译它
因此,将最终的 Dockerfile 命令更新为:
RUN RUSTFLAGS='-Cllvm-args=-inline-threshold=10 -Cllvm-args=-inlinedefault-threshold=10 -Cllvm-args=-inlinehint-threshold=10'
cargo chef cook --release --target=x86_64-unknown-linux-musl --recipe-path=/workdir/recipe.json
...
RUN RUSTFLAGS='-Cllvm-args=-inline-threshold=10 -Cllvm-args=-inlinedefault-threshold=10 -Cllvm-args=-inlinehint-threshold=10'
cargo build --timings --release --target=x86_64-unknown-linux-musl --package web-http-server
并对主 crate 进行许多其他小更改:
$ git diff --stat base..HEAD -- web-http-server
...
10 files changed, 898 insertions(+), 657 deletions(-)
以及对较大依赖项的一些更改:
- 将泛型函数改为非泛型:https://github.com/pulldown-cmark/pulldown-cmark/pull/1045
- 构建一个包含非通用版本的单独 crate:cargo-chef 中的更改,一个新的本地 crate 暴露了我从 lol_html 和 deadpool_postgres 中使用的 API 的非通用版本
… 使最终编译时间为 32.3 秒。
2025-06-27 更新
事情变得复杂了!
在分享这篇帖子后,Bluesky 上的一些人给出了很好的建议!
- 启用
-Zshare-generics
;以及 - 切换至非Alpine镜像
启用 -Zshare-generics
Piotr Osiewicz 在 Bluesky 上建议启用 -Zshare-generics
:
它将重用 crate 依赖项中的泛型实例化。由于它对代码生成有负面影响,因此(默认情况下)未在发布版本中启用。
[ … ]
该标志仅在夜间版本中可用,但即使使用稳定的工具链,它也会在开发版本中启用。
听起来不错!让我们尝试启用它吧!
RUSTFLAGS="-Zshare-generics -Cllvm-args=-inline-threshold=10 -Cllvm-args=-inlinedefault-threshold=10 -Cllvm-args=-inlinehint-threshold=10" ...
最终结果很有趣——总编译时间从 32.3 秒减少到 29.1 秒,尽管我们之前编译的许多 core::ptr::drop_in_place
仍然存在。
看看最大的时间,只过滤其他 crates 暴露的具体类型的 drop_in_place
:
$ # Before
$ cat ... | jq ...
| grep -P 'core::ptr::drop_in_place::<(?!web_http_server)[a-zA-Z0-9_:]+>$'
| head -n5
0.42s core::ptr::drop_in_place::<tracing_subscriber::filter::directive::ParseError>
0.13s core::ptr::drop_in_place::<http::uri::Uri>
0.12s core::ptr::drop_in_place::<toml_edit::item::Item>
0.11s core::ptr::drop_in_place::<std::io::error::Error>
0.1s core::ptr::drop_in_place::<hyper::body::incoming::Incoming>
$ # After
$ cat ... | jq ...
| grep -P 'core::ptr::drop_in_place::<(?!web_http_server)[a-zA-Z0-9_:]+>$'
| head -n5
0.59s core::ptr::drop_in_place::<hyper::ext::Protocol>
0.28s core::ptr::drop_in_place::<http::header::map::HeaderMap>
0.1s core::ptr::drop_in_place::<std::io::error::Error>
0.09s core::ptr::drop_in_place::<http::uri::Uri>
0.08s core::ptr::drop_in_place::<tokio::runtime::io::registration::Registration>
它们之间有一些变化,但仍然为许多依赖项编译相同的 core::ptr::drop_in_place
!
尽管如此,优化时间仍大幅减少——仅考虑 drop_in_place
实例化时,从 21.7 秒降至 17.4 秒,若考虑所有内容则降幅更大(从 128 秒降至 104 秒;跨多个线程,包含 LLVM 剖析的开销)。
放弃使用Alpine
Wesley Moore在Lobsters上的文章(via Bluesky) 建议放弃使用Alpine,因为默认分配器对编译时间的影响:
根据我的经验,分配器对构建时间有很大影响。例如,当Chimera Linux从scudo(已经比默认的musl分配器更好)切换到mimalloc时,Gleam的干净构建时间从67秒缩短到46秒。
类似的结果可以通过在Docker中切换构建的基础镜像来观察[ … ]
这带来了巨大差异。
在将 alpine 替换为 debian 并移除 --target=x86_64-unknown-linux-musl
后,总编译时间从 29.1 秒大幅缩短至 9.1 秒!
最终总结
- 我们从 175 秒开始
- 禁用 LTO(和调试符号!)后,时间缩短到 51 秒(-71%)
- 将最终 crate 更改为
opt-level = 1
后,时间缩短到 48.8 秒(-4%) - 通过
-C llvm-args
减少内联,使时间降至 40.7 秒(-16%) - 本地更改使时间降至 37.7 秒(-7%)
- 涉及依赖项的更改使时间降至 32.3 秒(-14%)
更新于 2025-06-27:
- 启用
-Zshare-generics
使运行时间缩短至 29.1 秒(-10%) - 切换至非 Alpine 环境使运行时间缩短至 9.1 秒(-69%)
接下来该怎么办?
尽管我遇到了很多问题,但工具确实工作得非常出色——而且文档对于经验相对较少的人来说也足够清晰,能够对代码库进行有意义的改进。
其中一些问题比较简单:修复 bug 以提供更好的体验,供下次遇到类似问题的开发者使用。
其他问题则更为复杂:
- 异步函数的深度调用图的编译时间需要改进——可能是 LLVM 存在一个容易触发的特殊边界情况,而
rustc
生成的代码恰好触发了它,或者可能是其他语言中未充分利用的糟糕启发式算法。 rustc
可能需要对core::ptr::drop_in_place<T>
进行特殊处理,以便在定义T
的 crate 中进行编译。这种方法并不适用于所有情况(例如泛型),但可以避免下游 crate 需要多次重新编译相同的析构函数。- 更新 2025-06-27:
-Zshare-generics
确实有帮助,但并非完全解决问题。不过,与此同时,我发现这实际上是一个 之前已经讨论过的问题——遗憾的是,由于编译了所有原本不会使用的 drop 胶水,这似乎 导致编译时间严重增加 由于编译了所有本应未使用的掉落胶水代码。可能存在某种折中方案(例如,优先考虑最终二进制文件的编译时间而牺牲依赖项的编译时间),但很难确定哪种方法是正确的。
- 更新 2025-06-27:
- 可能还有工具可以帮助隔离代码库中在编译过程中占用最多时间的部分(并提供缓解建议)——尽管这比这篇帖子更长期的项目。
与此同时,将 opt-level = 0
设置为默认值可能已经足够 🙂
对于这篇文章,你的反应是: