为什么 Rust 编译器这么慢?
我花了一个月的时间在 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 '\0' '\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
设置为默认值可能已经足够 🙂
本文文字及图片出自 Why is the Rust compiler so slow?
你可能听说过一个叫瑞安·弗勒里(Ryan Fleury)的人,他为Epic开发了RAD调试器。整个项目使用了27.8万行C代码,并以Unity构建方式实现(所有代码被整合到一个文件中,作为单一翻译单元进行编译)。在性能不错的 Windows 机器上,进行一次干净的编译需要 1.5 秒。这似乎是一个明显的案例研究,表明编译可以非常快,这让我感到奇怪,为什么其他语言,比如 Rust 和 Swift,不能做类似的事情来达到类似的速度。
编译器在构建时为你做的工作越多,构建所需的时间就越长,道理就是这么简单。
Go 即使在庞大的代码库上也能实现亚秒级构建时间。为什么?因为它在构建时不做太多事情。它拥有简单的模块系统、(相对)简单的类型系统,并将大量工作交给垃圾回收器在运行时处理。这对于其设计用途来说非常出色。
当你需要宏、高级类型系统,并希望在构建时获得稳健性保证时,你就必须为此付出代价。
我认为这基本上是一个神话。如果你看看 Rust 编译器的基准测试,虽然类型检查并不是免费的,但它也不是瓶颈。
C 和 C++ 的合并构建之所以能飞速运行,是因为它们无需重新解析头文件,且仅生成一个对象文件,因此链接器无需做任何工作。
一旦在工具链中添加静态链接(以任何形式),速度就会变得极其缓慢。
代码生成也是一个问题。Rust 往往比 C 或 C++ 生成更多的代码,因此,虽然编译器完成了大部分类型检查工作,但后端和汇编器还有许多工作要做。
它不仅生成更多的代码,而且优化之前最初生成的代码往往更差。例如,大量使用迭代器意味着需要实例化大量泛型,并生成大量用于设置和销毁调用栈的调用代码。这些代码会被大量内联和扁平化,因此最终优化得非常出色,但这对编译器来说是一项巨大的工作量。用传统的 for 循环和 if 语句手动编写所有代码是可能的,但这样会让代码更难阅读。
Swift编译器的瓶颈在于类型检查。例如,作为语言要求,泛型类型在编译后基本上保持原样。它们的类型检查与实际发生的情况无关。这与C++模板不同,C++模板在每次类型解析时都会将解析后的类型与泛型进行复制粘贴。
这有其权衡:以更长的编译时间为代价换取更高的ABI稳定性。
类型系统是导致 Rust 构建缓慢的原因,这是一个普遍且持久的误解。实际上,“cargo check”(只进行类型检查)通常非常快。大部分构建时间都花在代码生成阶段。如你所提,某些宏确实会引发问题,因为包含宏的代码必须在使用该宏的代码之前编译,从而降低并行性。
我刚刚对nushell运行了cargo check,耗时一分半钟。我没有测量编译时间,但今天早些时候可能花了五分钟?因此我认为它更快了,但仍不算快。
我原本很兴奋地想要进行“cargo check; mrustc; cc”的速度快 100 倍的实验,但我认为,即使成功了,速度也只会快一点点。
你是从干净的构建开始的吗?如果是的话,这个指标其实有点误导,因为 rust 需要先编译宏才能对使用宏的代码进行类型检查。(因此也必须编译宏所依赖的所有代码。)我建议这个实验是我的失误,哈哈。增量 cargo 检查通常是更佳的衡量类型检查耗时的方式,因为通常你没有修改任何需要重新编译的宏。在我工作的项目中,增量 cargo 检查耗时 `1.71s`。
> 大部分构建时间都花在代码生成阶段。
我能理解这一点,但即便如此,这也是由于类型系统将一切都单一化所致。当你使用 libc 中的 qsort 时,你使用的是库中预编译的代码。当你使用 slice::sort() 时,你会得到为你的应用程序定制的汇编代码。因此,代码生成量会大大增加,这是类型系统所做的权衡造成的。
Rust 的方法给你带来了各种优势,比如快速的代码和强大的编译时类型检查。但它也有缺点,比如二进制文件过大,而且 slice::sort() 中的错误无法通过运送 std dynamic 库来修复,因为没有这样的库。它已为您重新编译。
顺便说一句,现代 C++(如 Boost)将所有内容放在模板的 .h 文件中,也面临相同的问题。如果 Swift 也存在此问题,我敢打赌原因相同。
是的,但我还要补充一点,Go语言本身并不擅长优化。
编译器优化的是编译速度,而非运行时性能。总体来说,它的表现还算不错。尤其是因为它的应用场景往往是那些“足够好”就足够好的应用(例如,I/O密集型应用)。
你可以通过“gccgo”看到这一点。编译速度较慢,但运行速度较快。
gccgo真的更快吗?上次我查看时,它似乎已被放弃(停留在Go 1.18版本,不支持泛型),且并未比“实际”编译器更快。
深入研究后,发现这取决于工作负载。
对于纯计算型工作负载,它会更快。然而,涉及大量内存分配的场景会受到影响,因为 gccgo 的垃圾回收(GC)及相关优化似乎不如 cgo 优秀。
Dlang编译器比任何C++编译器都做更多工作(元编程、更优的模板系统和编译时执行),且速度快得多。语言语法设计在此起到了作用。
因为Russt和Swift比C编译器做了更多工作?借用检查器所需的分析并非免费,同样地,这两种语言中的许多其他编译时检查也需要成本。C 语言之所以能实现高速,是因为它基本上不进行超出基本语法范围的编译时检查,因此你可以用 foo(int) 调用 foo(char) 并进行其他不恰当的操作。
借用检查器通常只是编译时间曲线中的一个小波动。
但总体原则是合理的:做些工作确实比什么都不做要好。但借用检查器和其他安全检查并不是 Rust 编译时间性能的根本原因。
虽然借用检查器是一个很大的区别,但它当然不是 Rust 编译器在 C 之上提供的唯一需要更多工作的事情。
例如插入边界检查会增加优化阶段和代码生成后端的开销,因为它必须处理更多指令。这反过来又会增加链接器输入中的符号数量和段落大小,从而减慢链接速度。即使前端“证明”某项计算是多余的,该计算本身也不是免费的。许多这些特性与语言目标相关的“安全性”有关。我怀疑语法本身并不会带来太大差异,因为解析器通常也不是性能分析中的重点。
总体而言,它提供了更严格的检查,这些检查在 C/C++ 世界中通常会被推迟到静态分析工具中处理——而没有人会指责 Clang-Tidy 速度快 😛
这些语言在编译时确实做了更多工作。然而,我从Ryan的Discord服务器上了解到,他在C++代码库中进行Unity构建时也得到了类似结果(仅比C代码慢几秒)。此外,文章中提到大部分时间都花在LLVM和链接阶段。而Unity构建几乎完全省去了链接步骤。Rust 和 Swift 做了些复杂的事情(hinley-milner、泛型等),但我怀疑这些事情会导致速度最慢。
这不是一个好的例子。Foo(int) 被编译器分析,并插入了类型转换。语言规范可能不好,但这并不是让编译器偷工减料。
如果你希望 Rust 编译器快速运行:
* 不要使用嵌套类型——这些会大大降低编译器速度
* 不要使用 crates,或者使用那些强调编译器速度的 crates
C 仍然非常快。这就是我喜欢它(和 Rust)的原因。
在关于 Rust 编译器速度的讨论中,这个解释被反复提及,但除了极少数病态情况外,发布版本的大部分时间并不是花在编译时检查上,而是花在 LLVM 上。Rust 有零成本的抽象,但零成本是指运行时,遗憾的是,编译时会生成大量垃圾,LLVM 必须努力清除这些垃圾。它做得很好,但代价是编译速度变慢。
是否可以减少垃圾的生成?听起来像编译器开发人员走了一条捷径,随着时间的推移,这可能会得到改善。
您可以通过让通用函数尽可能地将工作委托给非通用或“较少”通用函数来手动解决垃圾问题(其中,“较少”通用函数是指只依赖于已知类型特征子集(如大小或对齐)的函数。这样委托可以帮助编译器生成更少的冗余代码副本,即使它无法完全避免代码单态化。)
可能可以,但据我所知,这需要对编译器架构进行大量相当显著的改造才能真正取得进展。
我认为观察C代码可以快速编译(Go语言也是如此,它专门设计用于快速编译)并不有趣。这并非编译本身的问题;真正有趣且困难的问题是让Rust的语义快速编译。这是Rust官网上的一个常见问题。
我在2000年代遇到过一个用C++编写的项目,代码量只有几十千行。它在老旧电脑上编译只需几分之一秒。而我用Boost编写的Hello World代码编译则需要几秒钟。因此这不仅取决于语言本身,还取决于代码结构以及是否使用了编译成本较高的特性。我敢肯定,即使你用C宏编写Doom,它也不会很快。我也确信,你可以用 Rust 编写出编译速度非常快的代码。
我非常感兴趣地想看看功能/模式列表以及它们对编译器造成的成本。理想情况下,人们应该能够使用整个语言,而无需等待太久才能得到结果。
模板作为单一特性,其影响范围极广。它对编译时间的影响可能无法量化。或者,你只需编写几十行代码,就可能导致编译耗时数小时。
我在该项目中观察到几种典型的模式。请注意,其中许多模式被许多人视为反模式,因此我并不建议使用它们。
使用指针,且不要在头文件中包含类文件,如果你需要指向该类的指针。我认为这是C++中一个相当成熟的模式。因此,如果你想在头文件中声明指向类的指针,只需写`class SomeClass;`而不是`#include “SomeClass.hpp”`。
不要使用STL或IOstreams。该项目仅使用libc和POSIX API。我知道该作者非常讨厌STL,并认为将其纳入标准语言是一个巨大的错误。
除非绝对必要,否则避免使用泛型模板。模板迫使你将代码写在头文件中,因此每次包含时都会被多次解析、编译成多个副本等。即使使用模板,也尽量将类拆分为通用部分和非通用部分,以便部分代码可从头文件移至源文件。通常应优先使用运行时多态性而非通用编译时多态性。
既然如此,为何还要使用 C++?此外,预先声明类而非包含对应头文件存在诸多缺点。
RAII?共享指针?
我敢打赌,如果你将这 278k 行代码用简单的 Rust 重写,不使用泛型或宏,只使用一个 crate,没有依赖项,你可以获得非常相似的编译时间。如果代码很简单,Rust 编译器可以非常快。只有当你有依赖和繁重的抽象(宏、泛型、特质、深层依赖树)时,事情才会变得缓慢。
我对你提到的依赖关系感到好奇。这个 Rust 项目(https://github.com/microsoft/edit)基本上没有依赖关系,有 17,426 行代码,在 M4 Max 上,调试编译时间为 1.83 秒,发布编译时间为 5.40 秒。代码看起来也很简单。编辑:请注意,这比 OP 的项目多出了 10k 行。这无疑让这些依赖关系变得可疑。
“基本上没有依赖关系”并不完全正确。它依赖于“windows”crate,这是微软自动生成的 Win32 绑定。“windows” crate 非常庞大,会引入数十万行代码。
其中还有其他一些依赖项,仅在构建测试/基准测试时使用,如 serde、zstd 和 criterion。您需要确保只构建库,而不构建测试框架,以确保这些依赖项不会被构建。
我忍不住觉得借用检查器 alone 就会使速度至少慢 1 或 2 个数量级。
你的直觉是错误的:借用检查器并不需要太多时间。
借用检查器其实并不昂贵。以一个随机的例子来说,在正则表达式 crate 的发布版本中,我看到 borrowck 占用了不到 1% 的时间。超过 80% 的时间花在了代码生成和 LLVM 上。
再次重申,正如经常被重复和数据所证明的那样,借用检查器只占 Rust 应用程序构建时间的一小部分,大部分时间都花在了 LLVM 上。
我看到的关于统一构建速度快的说法,对我来说都不太可信。我刚刚下载了 rad 调试器,并在 7950x(大约是最快的)上运行了构建脚本。调试构建花了 5 秒,发布构建花了 34 秒,无论使用 gcc 还是 clang。
也许这是 MSVC 的问题——它似乎有一些多线程的东西。无论如何,raddbg 非干净构建比我的任何 Rust 项目都花的时间更长。
这有点令人惊讶。SQLite 的“统一”构建版本,其 C 代码行数与之相当,但编译时间却要长得多。
> 这让我好奇,为什么像 Rust 和 Swift 这样的其他语言不能通过类似的方式实现相似的速度。
Rust 的主要特点之一是广泛的编译时检查。单态化也是一项复杂的操作,这并不是 Rust 独有的。
C 的编译时间应该非常快,因为它是一种相对低级的语言。
从编程语言及其编译时复杂性的整体来看,C 代码更接近汇编语言,而不是 Rust 或 Swift 等现代语言。
“只是”。可能是因为你忽略了其中的复杂性。几乎没有什么事情是“只是”那么简单的。
在之前的公司,我们有一条规则:谁说“只是”就得去实现它 🙂
我原本想禁止使用“只是”,但你的规则更好。太棒了。
那个“只是”太轻率了。我的错。我原本想表达的是:“嘿,这里有一些快速编译正在进行,而且实现起来并不难。我们至少可以看看为什么会这样,然后也许也能做到同样的事情吗?”
> “嘿,这里有一些快速编译正在进行,而且实现起来并不难。我们至少可以看看为什么会这样,也许可以做同样的事情吗?”
你真的相信在 Rust 的整个生命周期中,没有人看过 C 编译器,也没有人考虑过他们使用的技术是否可以应用到 Rust 编译器上吗?
当然不是。但如果没有人想到使用统一构建,我也不会感到惊讶。(也许他们想到了。我不知道。我很好奇)。
Rust 和 C 在编译单元方面存在差异:Rust 的编译单元平均而言往往比 C 大得多,因为整个 crate(即模块树)是 Rust 中的编译单元,而 C 的编译单元则是基于文件的(如果你使用的是某些奇怪的架构,则例外)。
统一构建对 C 程序非常有用,因为它们往往可以减少头文件处理的开销,而 Rust 根本没有预处理器或头文件。
它们还可以帮助减少对象文件的数量(从许多减少到一个),从而减轻链接器的负担,由于我上面提到的原因,这已经基本实现了(虽然并不是完全实现)。
一般来说,传统的建议是做完全相反的事情:将大型的 Rust 项目分成更多、更小的编译单元,可以减少“虚假”的重建,因此较小的更改对整体的影响较小。
基本上,Rust 的编译时间问题在于其他方面。
你能解释一下为什么统一构建会有所帮助吗?传统观点认为,Rust 编译速度慢的部分原因是它有太少的翻译单元(每个 crate 一个,再加上有时才起作用的代码生成单元),而不是太多。
Rust 在幕后做了很多工作。C 语言不会跟踪变量的生命周期、所有权、类型、泛型,也不会处理依赖关系管理或编译时执行(除了预编译器这种有限的语言之外)。当你犯错时,rust 编译器还会给出智能(非常智能!)的建议:它需要大量的上下文才能做到这一点。
Rust 编译器实际上非常快,能够完成所有的工作。只是需要做的工作量非常多。你不能指望它像 C 语言一样快速编译。
我很高兴 Go 选择了另一种方式:优先考虑编译速度,而不是优化。
对于我从事的工作——编写服务器、网络和胶水代码——快速编译绝对是重中之重。同时,我希望有一些类型安全,但不是那种过于烦人的类型安全,让我无法随意原型设计。此外,垃圾回收也起到了帮助作用。所以我愿意为此付出代价。不需要处理符号 soup 也是另一个优点。
我想谷歌多年的经验让他们得出结论:对于软件开发要实现规模化,简单的类型系统、GC 和极快的编译速度比 raw 运行时吞吐量和语义正确性更重要。考虑到用 Go 编写的网络和大型基础设施软件的数量,我认为他们绝对做对了。
当然,也有一些场景无法容忍GC,或者正确性比开发速度更重要。但我并不从事这类工作,对Go做出的权衡感到非常满意。
这就是Go语言的初衷,而选择合适的工具来完成任务再好不过了。我见过唯一一个让人头疼的问题是,通过通道共享可变状态时,并发性可能会以一种微妙且易被利用的方式出错。不过,我觉得大多数人并不是这样使用通道的。我使用 Rust,是因为我的工作不需要这样做。我通常需要将缓慢的算法塞进更慢的硬件中,而这些问题通常几乎但并非完全属于令人尴尬的并行问题。
Go 语言在谷歌现在还被广泛使用吗?
如果不用 Go 语言,他们会用什么来做网络通信?
那个人似乎搞错了。安装一个静态链接的二进制文件显然比管理一个容器更简单!
这也让我觉得他们可能没有完全理解Docker到底在做什么。关于在Docker镜像中构建一切:
“不幸的是,每次有任何更改时,这都会从头开始重建一切。”
在这种情况下,只有一个人作为构建者,不需要CI或CD或其他任何东西,本地构建并享受所有本地便利,然后将结果导入Docker容器中,这没什么问题。请仔细检查任何可能意外添加路径的设置,如果这些路径包含让你感到困扰的内容。(以我为例,这只会揭示有人用我的用户名构建了它,并且他们有一个“src”目录……你可以从我公开发布这些细节的事实中看出我对这两点有多担心。)
在专业环境中,CI/CD 需要确保你可以从硬盘、磁针和一只经过训练可以在上面刮出最小内核的猴子开始构建项目,并从那里启动,但个人项目不需要这样。
谢谢!我刚开始做了一会儿就一头雾水。没有必要在容器中进行构建。
即使在工作中,我也有几个项目需要构建一个 Java uber jar(将所有依赖项打包到一个大文件中),当需要容器化时,我们只需将 jar 文件复制进去。
我真的看不到在容器中进行构建的必要性,除非我的 CI/CD 管道存在某些限制,导致无法访问必要的构建工具。
容器化的核心意义之一就是实现可重复构建。你需要一个可以完全信任的构建环境,确保每次构建结果完全一致。而主机机器并非如此。如果你运行
pacman -Syu
,你的构建环境就不再与之前相同。如果你现在将二进制文件复制到容器中,而它隐式假设/usr/lib或其他位置存在共享库,那么由于库版本不匹配,运行时可能会出错。
完全正确。我读到这段话时,立刻想到了grug脑开发者。
从文章中可以看出,目标不是简化,而是现代化:
> 因此,我希望改用容器部署我的网站(无论是 Docker、Kubernetes 还是其他容器),这与过去十年中绝大多数软件的部署方式一致。
容器提供了许多优势。例如:进程隔离、增强的安全性、标准化的日志记录以及成熟的水平扩展能力。
将二进制文件放入容器中。为什么必须在容器内编译?
这就是他们正在做的事情。这是一个两阶段的 Dockerfile。
第一阶段编译代码。这有利于隔离和可重复性。
第二阶段是一个轻量级容器,用于运行编译后的二进制文件。
为什么作者因未简化流程而遭到多条评论攻击,而这本就不是其目标。他们正在进行现代化改造。
容器本就是CI/CD的最佳实践。
我不明白为什么“不必要地复杂化”会被视为更现代的做法。
不要做不需要做的事情。
你意识到作者正在为一个静态网站编译一个 Rust 网页服务器吧?
他们早已超越了“不必要地复杂化事情”的阶段。
相比之下,一个简单的 Dockerfile 显得微不足道。
这是一个合理的部署策略,但却是相当糟糕的本地开发策略。
Devcontainers 是一个不错的折中方案——你可以在与生产环境几乎完全相同的上下文中进行开发;通过一些调整,你甚至可以使用相同的 Dockerfile 用于开发容器、构建镜像和部署镜像
因为他花了很多时间在开头抱怨这会让他的开发实践变慢。所以别这么做!这与 Docker 无关,而是因为他每次触发构建时都会清除缓存。
尽管极力克制住轻率的冲动,但所有这些优势在 Docker 出现之前就已经实现了。
Docker 是一种(在某些领域)现代化的实现方式,但绝非唯一途径。
与裸机相比安全性更高,但低于虚拟机(VM)。此外,也低于Jails和RKT(Rocket),后者似乎已不再活跃。
> 进程隔离,安全性提升
不,那是沙箱化。
我并不认为它有多慢。它的性能似乎与其他同等复杂度的语言差不多,而且比我归入同一类别的 C++ 和 Scala 15 分钟的构建时间快得多。
我也搞不懂这一点,我在工作时几乎不会受到 Rust 编译器的干扰。我觉得这是因为它早期表现很差,人们只是坚持这种说法罢了。
当 C++ 模板是图灵完备的,如果不考虑实际代码,抱怨编译时间是没有意义的 🙂
作为前 C++ 开发人员,我对 Rust 编译速度慢的说法感到困惑。
这也是 Rust 被认为是针对 C++ 开发人员的原因之一。C++ 开发人员已经具备容忍该工具的斯德哥尔摩综合症。
Rust 的编译速度较慢,但该工具几乎是所有编程语言中最好的。
调试器有多好?“编辑并继续”?热重载?完整的 IDE?
我对 Rust 了解不够,但我发现这些方面在 Linux 上的 C++ 中严重缺乏,这是我认为 Windows 对开发人员更有利的少数方面之一。Rust 更好吗?
> 调试器
我真正使用过调试器的是在嵌入式系统上,我们使用的是 gdb。我知道 VS: Code 有一个可用的调试器,我相信其他 IDE 也有。
> 编辑并继续
在没有运行时的预编译语言中很难实现,如果你问的是我认为你问的问题。
> 热重载
其他人给你提供了很好的链接,但这些东西还比较新,所以我不会说这很好或经常很好。
> 完整的 IDE
我不知道是否有专门针对 Rust 的 IDE,但许多 IDE 都对 Rust 提供了很好的支持。根据年度调查,VS: Code 是用户最受欢迎的 IDE。Rust 项目分发了一个官方的 LSP 服务器,因此你可以将其与任何支持它的编辑器一起使用。
> 调试器有多好?“编辑并继续”?
相关:Subsecond:Rust 热重载的运行时热补丁引擎 – https://news.ycombinator.com/item?id=44369642 – 2024 年 6 月(36 条评论)
> 完整的 IDE?
https://www.jetbrains.com/rust/(新推出的非商业用途免费版本)
> 发现这些方面在 Linux 上的 C++ 中严重缺乏
https://www.jetbrains.com/clion/(相同,非商业用途)
我不知道,因为我从未做过。我认识的任何 Rust 程序员也没有做过。这也许可以回答你的问题 😉
此外,具有值语义的现代 C++ 比许多其他语言更具功能性,人们可能会从这些语言转向 Rust,这使借用检查器不再那么令人讨厌。如果人们习惯于创建相互引用状态类网络。借用检查器非常可怕,但这是因为如果进行多线程处理,这种设计模式非常可怕。
> 斯德哥尔摩综合症
又名“记住瓦萨号!”https://news.ycombinator.com/item?id=17172057
即使不像 C++ 那样慢,从绝对意义上讲,事情仍然可能会很慢。C++ 编译的问题已经得到了非常好的理解和记录。它是地球上编译时间最长的语言之一。Rust 没有这些语言级的问题,因此人们对它的期望自然更高。
Rust 与 C++ 在编译时间方面几乎存在所有语言级问题,不是吗?单态化爆炸、图灵完备的编译时间宏、复杂的类型系统。
两者有很多重叠之处,但情况并不那么简单。除非你也不考虑 C++ 继承的 C 问题。即使如此,两者之间仍然存在一些重要的细微差别。
但它们确实有一些共同的问题。具体来说,虽然 Rust 通用类型不像 C++ 模板那样无结构,但主要负担实际上来自编译所有这些微小的实例化,而 Rust 单态化也有同样的问题,占用了大部分编译时间。
我非常欣赏C在封装和减少编译步骤方面的努力,通过编译后链接来实现……然而C++却通过要求一切都使用模板,几乎推翻了这些努力。
哎呀,修改了一个头文件中的一个模板。这影响了……我代码的98%。
增量编译效果良好。若需降低中间状态逐步污染缓存的风险,可在单次全新构建后冻结初始增量缓存,用于构建/部署更新。
与Docker配合使用效果出色:当编译器版本更新或网站进行重大更新时,使用增量缓存重建层;否则直接从快照运行,构建最新网站更新版本/状态,并上传/部署生成的静态二进制文件。只需进行设置,即可确保简单的代码更改不会强制重建缓存/实现全新干净构建的增量编译缓存的层。
Rust 编译器非常快,但语言功能太多。
速度慢是因为每个人都必须以 Java Enterprise 风格使用泛型和宏编写代码,以展示自己精通 Rust。
这真的令人遗憾,但大多数库都过度使用代码生成功能。
如果你想在 Rust 中实现快速编译,就必须手动编写很多东西。
代码的编译速度似乎并不是社区的优先考虑事项。
是的,根据我的经验,对于应用程序代码,我越坚持愚蠢的做法,就越不会与借用检查器发生冲突,而且特性问题也会越少。
重构似乎也需要花费相同的时间,因此在这方面没有损失。最终,我只需要修复各种逻辑错误,这(至少对我而言)是理所当然的,但我还是会怀疑自己是否真的做对了。
我想也许两年后,人们会建议避免使用泛型并限制宏的使用。如今,大多数人已经听说过关于不要在第一次编译时过分关注克隆和解包的建议(尽管我认为expect要好得多)。
什么什么闪亮工具综合症?
我的主页重建需要73毫秒:17毫秒用于重新编译静态网站生成器,然后56毫秒用于运行它。
andy@bark ~/d/andrewkelley.me (master)> zig build --watch -fincremental 构建摘要:3/3个步骤成功 安装成功 └─ 运行可执行文件编译成功 57ms MaxRSS:3M └─ 编译可执行文件编译调试原生成功 331ms 构建摘要:3/3 步成功 安装成功 └─ 运行可执行文件编译成功 56ms MaxRSS:3M └─ 编译 exe 编译调试本地成功 17ms 监视 75 个目录、1 个进程
就像关于 C/C++ 的每条提交都会收到关于 Rust 有多棒的评论一样,关于 Rust 的每条提交都会收到关于 Zig 有多棒的评论。就像钟表一样准。
编辑:显然,我是在回复 Zig 的主要作者?语言宣传是 Rust 最糟糕的部分,它可能激起了更多反 Rust 的情绪,而不是让人们“转投” Rust。如果你真的关心你的语言,你应该利用你所能利用的一切来引导你的社区远离宣传,而不是拥抱它。
不错吧?
如果这条评论与所发布的文章相关,或者真的有超越单一编译时间指标的见解,那么这条评论会更好。你想让我从你的评论中得到什么结论?Zig 好,Rust 坏?
我认为最相关的是,构建一个简单的网站应该(而且必须)只需几毫秒,而不是几分钟,正如帖子中所说:
> 附带说明:50秒其实是可以接受的!
50秒实际上不应被视为可以接受的。
正如你刚刚所示,这个观点无需提及Zig,更不用说复制粘贴一些编译时间数据而没有其他评论或上下文。这就是为什么我认为(或者说希望)其中可能还有更多内容,而不仅仅是试图贬低Zig。
现在我们得到了所有这些与主题无关的关于Zig的讨论。我想这对你们Zig的人来说是好事……但对我来说,这相当令人反感。
whoisyc的评论非常切中要点。作为社区副总裁,我真的鼓励大家思考他们所说的话。
> 正如你刚刚所示,这个观点无需提及Zig,更不用说直接复制粘贴一些编译时信息而没有任何其他评论或上下文。这就是为什么我认为(或者说希望)其中可能还有更多内容,而不仅仅是试图打断讨论。
拥有具体证据证明某件事可以更高效地完成极为重要,而我之前并未“证明”任何内容,因为若没有之前的上下文,我的评论将缺乏实质性内容。
Andrew 的评论并非随机的编译器统计数据,而是一个数据点,展示了具有可比性的示例却呈现出截然不同的性能特征。
您可以在这个HN帖子中看到各种评论,这些评论认为Rust的编译器性能无法提升,而原因实际上大多(如果不是全部)与主题无关。例如,有人提到Rust的编译时间较长是因为借用检查器(以及其他安全检查),而Steve指出,实际上编译管道中这一部分非常小。
> 现在,我们得到了关于 Zig 的所有这些离题讨论。
所以,不,我会提出相反的观点:这个讨论非常符合主题。
@AndyKelley 我非常好奇,你认为是什么主要因素使 Zig 这样的语言在编译方面非常快,而 Rust 和 Swift 这样的语言却相当慢。关键区别是什么?
基本上,不依赖于LLVM或LLD。上述情况仅因我们投入数年时间开发了自己的x86_64后端和链接器才得以实现。你可以看到两年前所有人都在嘲笑这个决定 https://news.ycombinator.com/item?id=36529456
LLVM 并不是一个好的替罪羊。与 rust 或 c++ 应用程序大小相当的 C 应用程序的编译速度要快一个数量级,而且它们都使用 LLVM。我不是编译器专家,但我认为 Zig 快速编译的唯一途径是定制后端,这似乎不太合理。
尽管如此,许多 C 编译器仍然比 LLVM 快一个数量级。最好的例子可能是 tcc,但它并不是唯一的一个。C 比 Rust 简单得多,因此预计 C 的编译时间应该更短。但这并不意味着 llvm 对编译速度没有显著贡献。我认为 cranelift 对 Rust 的编译也比 llvm 路径快得多。
> 这并不意味着LLVM不是编译速度的重要贡献者。
这不是我所说的。我说的是,使用LLVM实现快速编译是不太可能的,而我认为,这正是由存在一个使用LLVM的快速编译器所证明的。
它会更快地编译一个数量级,因为它通常不会做相同的事情——例如,在 C++、Rust 或 Zig 中积极内联的函数会单独编译并正常链接,而且通常 C 代码中没有与编译时泛型相当的东西(因为你必须手动拼写所有实例化,或者使用预处理器或代码生成器来完成在 C++ 中只需两行代码就能完成的事情)。
Rust 团队有 cranelift 和 wild。LLVM 和 LLD 还有其他替代方案,尽管对于大多数用户来说,这些替代方案可能并不明显。
我不是 Andrew,但 Rust 做出了一些语言设计决策,这些决策给编译器的性能带来了困难。编译器速度的某些方面就取决于此。
一个主要区别在于每个项目对编译器性能的考虑方式:
Rust 团队一直都在一定程度上关注这个问题。但据我对许多 RFC 的记忆,“这对编译器性能有什么影响”并不是首要关注的问题。而且,这也不太适用于 RFC 系统出现之前就基本实现的许多功能。因此,虽然这很重要,但与其他事情相比,它还是次要的。因此,尽管许多努力工作的人投入了大量精力来提升性能,但他们最终还是会遇到这些更根本的限制。
Andrew 非常明确地将编译器性能视为首要关注点,这影响了语言设计决策。自然而然,这导致了一个非常高效的编译器。
我也很好奇,因为我(最近)用 Zig 和 Rust 编译了几乎相同的程序,它们的编译时间相同。我猜人们只是用更少的代码和更少的依赖项编写 Zig 程序,并没有真正进行同类比较。
Zig 正在开始迁移到自定义后端进行调试构建(而不是使用 LLVM),再加上增量编译。
所有 Zig 代码都在一个编译单元中构建,每次更改时,所有内容都会从头开始编译,包括所有依赖项和项目中使用的 stdlib 的所有部分。
因此,您一直在将每次都执行所有工作的 Zig 重建与缓存所有依赖项的 Rust 重建进行比较。
一旦增量编译完全发布,您将看到即时重建。
这何时会应用到 Zig 中?是否支持 aarch64?
当目标为 x86_64 时,自宿主后端已在 Zig 的最新构建中默认启用(在调试模式下编译时)。自宿主 aarch64 后端目前尚不具备通用可用性(因此在针对 aarch64 时仍默认使用 LLVM),但它很可能是我们接下来重点优化的下一代指令集架构(ISA)。
我假设x86_64仅适用于Linux,对吗?
不完全正确——任何ELF或MachO目标默认已启用。Windows正在等待一些COFF链接器漏洞修复。
不错。没想到 zig build 添加了 –watch 和 -fincremental 选项。我之前主要是使用 “watchexec -e zig zig build” 来在文件更改时重新编译。
新增于 0.14.0!
我的非静态 Rust 网站(包括一个实际的网络服务器以及一个用于模板化的类似 react 的框架)使用“cargo watch”(这是一个外部监视器,它只是终止进程并重新运行“cargo run”)进行增量重新编译需要 1.25 秒。
如果使用类似 subsecond[0] 的工具(支持增量链接并热补丁运行中的二进制文件),速度可以快得多。虽然不如 Zig 快,但已经非常接近。
然而,如果上述 331 毫秒的构建是干净(未缓存)构建,那么这比我的网站干净构建(约 12 秒)快得多。
[0]: https://news.ycombinator.com/item?id=44369642
331毫秒的时间主要是未缓存的。在这种情况下,构建脚本已经缓存(如果构建脚本被修改,则必须重新缓存),而编译器_rt也已经缓存(每个目标必须精确执行一次;几乎从未重新构建)。
令人印象深刻!
Zig 不是内存安全的,对吧?
虽然不是很多东西,但我认为它非常(呵呵)好的异常处理模型/理念(使其变得良好、必要且性能良好)比内存安全性更重要,尤其是当许多以性能为导向/位操作的 Rust 代码最终还是被塞进 Unsafe 块时。即使 C/C++ 也可以实现内存安全,参见 https://github.com/pizlonator/llvm-project-deluge
我更感兴趣的是当前的运行时性能权衡情况;必须假设其性能低于 LLVM 生成的代码,否则这一重大成就似乎在极短时间内就被某种方式超越,且编译时间也大幅缩短。
> 尤其是当许多注重性能/位操作的 Rust 代码最终还是被塞进 Unsafe 块时。即使 C/C++ 也可以实现内存安全,参见
你的第一个说法无法验证,第二个说法则完全错误。即使是拥有才华横溢、高薪的 C 或 C++ 开发者的庞大项目,最终也会出现 CVE 漏洞,其中约 80% 与内存相关。人类在代码中无法实现 0% 的错误率。
如果 Zig 某天比 C/C++ 更受欢迎,我们仍将因内存不安全而陷入相同的 CVE 泥潭。谢谢,不要。
> 即使C/C++也能实现内存安全,参见https://github.com/pizlonator/llvm-project-deluge
> Fil-C 通过结合并发垃圾回收和不可见功能(内存中的每个指针都有一个对应的功能,在 C 地址空间中不可见)来实现这一点。
但这会带来巨大的性能和内存开销。这与 Rust 的情况完全不同,但如果你想将性能不敏感的 C 代码引入更安全的执行环境,这非常重要。
你有多相信内存安全性(或缺乏安全性)是影响编译器速度的一个重要变量?
Zig 的内存安全性低于 Rust,但高于 C/C++。Zig 和 Rust 基本上都不具备内存安全性。
什么?Zig 绝对不具备内存安全性,而安全的 Rust 则从定义上来说具备内存安全性。不安全的 Rust 不具备内存安全性,但通常情况下,您并不需要大量使用它。
安全的 Rust 显然不是内存安全的:https://github.com/Speykious/cve-rs/tree/main
这是编译器错误。这与语言本身无关。错误会发生,但会得到修复,这个错误也是如此。
这是一个存在了 10 年的错误,最终会得到修复,但可能需要更改 Rust 处理类型变异的方式。
在你们编写出真正的正式规范之前,编译器就是语言。
这是一个存在了 10 年的错误,因为在这 10 年里,它从未在实际应用中出现过。影响较小但实现难度较高的 bug 优先级低于影响真实用户的 bug。
该项目正在采用 Ferrocene 作为规范。
Ferrocene 旨在记录当前版本 rustc 编译器的行为,因此它只是对“编译器就是语言”这一概念的正式化。
是的,这个漏洞本身影响较小,无需优先处理,但它削弱了针对我提出的“Zig 绝对不具备内存安全,而安全的 Rust 则天生具备内存安全”这一二元论点。现在你面临的是关于实际影响的定性/定量问题,而我的原有陈述依然成立:“Zig 的内存安全性低于 Rust,但高于 C/C++。Zig 和 Rust 在根本上都不是内存安全的。
当然,你可以声明 Safe Rust 从定义上来说是内存安全的,但这并不比声明 Rust 解决了停机问题或证明了 P=NP 更真实。RustBelt 被证明是健全的。相比之下,正如 Ferrocene 所记录的那样,Rust 目前从根本上来说是不健全的(尽管你在实践中不会遇到健全性问题)。
一旦出现任何“不安全”的情况,Rust 就“从定义上”不再是内存安全的。
Zig 是一种小而简单的语言。它不需要复杂的编译器。
Rust 是一种大型且强大的语言,适用于严肃的系统编程。Rust 解决的问题范围很广,它寻求应用于非常大的软件问题。
这两者并不相同,不值得进行直接比较。
编辑:我对措辞做了一些修改。我将 Zig 描述为一种“玩具”语言,这并不是一个恰当的措辞。
这两种语言处于不同的成熟阶段,复杂程度不同,客户群也不同。不应该如此肤浅地将它们进行比较。
这是一个支持 Rust 的有趣论点,因为这正是 Ada 支持者对包括 Rust 在内的其他语言所做的轻蔑评价。
拜托,这可不行。这种行为是不可接受的。
(编辑:该帖子的作者已经编辑了这条评论,不再只是“zig 坏,rust 好”,但我仍然认为我发表这条评论时那种好斗和侮辱性的语气是不酷的。
> 但我仍然认为我发表这条评论时那种好斗和侮辱性的语气是不酷的
恕我直言,这位家长只提供了 Zig 的编译时间指标。仅此而已。这就是整个评论的内容。
关于 Rust 的这篇 HN 帖子现在被 Zig 的主要作者的一句廉价的 Zig 一行谦虚的吹嘘所主导。
我认为这个帖子需要多一点细微的差别。
顺便说一句,我认为你修改后的评论要好得多,尽管我不同意其中的一些表述,但至少有些实质内容。
对所谓的不良行为感到沮丧并不意味着用更多不良行为来回应是改善讨论的好方法,如果你在这里的目标是改善讨论的话。
你完全正确,史蒂夫。感谢你保持冷静的声音。你对这个社区的贡献令人惊叹。
没关系。我自己在很多时候也犯过错误。当我们看到对方越界时,我们都应该给予温柔的提醒。如果你看到我这样做,也请告诉我!
Cranelift 在哪里提到过
我对此的看法是,由于编译时间过长,我几乎要放弃 Rust 进行游戏开发了,经过挖掘,我发现无论优化级别如何,LLVM 都非常慢。事实上,这是 Jai 开发人员一直说的。
因此,Cranelift 可能与 OP 相关,我会无休止地推销它,它将我的游戏从 16 秒缩短到 4 秒。Cranelift 团队做得非常出色。
我参与了最近的Bevy游戏开发活动,社区中出现了一款名为subsecond的新工具,该工具由Dioxus开发,顾名思义,它提供了亚秒级的热重载功能。这使得原型开发变得非常愉快,尤其是在迭代用户界面时。
https://github.com/TheBevyFlock/bevy_simple_subsecond_system
我认为Zig团队也在做类似的事情,以实现非常快的构建时间:移除LLVM。
是的,Zig作者之前评论过[0]
[0] https://news.ycombinator.com/item?id=44390972
不错,我之前检查过,当时不支持 macOS aarch64,但现在似乎支持了。
等等。你因为 16 秒的构建时间就要放弃 Rust 吗?
随着时间的推移,这种情况会累积,尤其是当你的编码流程类似于REPL时。
每天工作时打开Instagram 100次,这简直是一场灾难
16秒的构建时间对于需要手动测试的功能(比如跳跃是否过于飘浮)来说令人抓狂。
但也有可能16秒只是开发初期阶段,之后情况会变得更糟。
这是我认识的第一个在HN首页上出现的人(嘿,sharnoff,恭喜)——
我认为这篇帖子(可能是无意中)混淆了两种不同的性能瓶颈来源:
1) 在 Docker 中构建项目 2) 编译器本身“运行缓慢”
他们提到可以使用绑定挂载,但又希望有一个干净的构建环境——就个人而言,我认为这可能是有误的。Rust 的增量构建实际上非常快,而您在处理 Docker 缓存时浪费的时间很可能在构建时间中得到补偿——因为您通常构建和部署的频率远高于处理缓存的频率(在这种情况下,您会删除缓存并从头开始构建)。
因此,对于构建 Rust 容器的开发人员,我强烈建议使用缓存挂载或在容器外进行构建,然后将二进制文件添加到映像中。
2) 编译器速度慢——与 ocaml、go 和 scala 相比,Rust 编译器确实比 go 和 ocaml 慢, 但在非交互式(即类似REPL)的工作流程中,这通常不会造成太大影响。实际上,开发模式下的增量构建只需几秒钟,一旦代码正常运行,你就可以将代码推送到CI,此时即使构建容器需要20分钟(最坏情况?),你也自由地去做其他事情。
因此,虽然我非常欣赏深入的研究和精彩的解释,但我并不认为 Rust 编译器真的慢,只是比人们习惯的 Typescript 或 Go 慢一些罢了。
很多人是在回复标题,而不是文章。
> 要将 Rust 程序放入容器中,通常的做法如下:
如果您的构建过程中有 `cargo build –target x86_64-unknown-linux-musl`,则无需在 Dockerfile 中执行此操作。您应该进行编译并复制到 /sbin 或类似位置。
如果你真的想在 Docker 镜像中构建,我建议使用 `cargo –target-dir=/target …`,然后使用 `docker run –mount type-bind,…` 运行,并从绑定挂载中复制到 /bin 或其他位置。
许多 Docker 用户在 arm64-darwin 环境下开发,但部署到 x86_64 (g)libc 环境,因此我认为这种方法并不普遍适用。
这些用户是错误的:耸肩:
我必须说,当我遇到一个开源项目,并意识到它是用 Rust 编写的时,我会有些退缩,因为我知道构建过程非常缓慢。这无疑是我学习它的阻碍之一。
Rust 编译器速度很慢。但如果你希望编译器具备更多功能,就必须使用速度较慢的编译器,这是无法避免的。然而,这篇博客文章似乎并不是在讨论这个问题,而是对二进制文件的部署方式感到不满。
你曾经拥有一个功能齐全且简化的部署流程(编译、复制、重启),而现在你拥有的是…
只需设置一个构建服务器,让你的 Docker 容器从该服务器获取预编译的二进制文件?
> 这……不太理想。
什么?这绝对是理想的!它极其简单。我希望部署流程永远都能这么简单!Docker 不会让你的部署流程比这更简单。
我确实享受深入研究编译过程中耗时较长部分的乐趣。
我喜欢 Alpine Linux 的一个地方是制作包非常简单且不易出错。它不像创建 `.deb` 文件那样复杂。
如果有人已经完全致力于只使用 Alpine Linux,我建议至少尝试一次创建原生包。
我对 .deb 包不太熟悉,但 Arch Linux 的 PKGBUILD 和 makepkg 让我着迷。创建包简直简单得令人发指。
这真是太奇怪了,简直是小题大做。
本地构建速度很快,为什么还要为一些小改动重新构建 docker 呢?
而且,为什么个人页面会有这么多 rust 和依赖项呢?对于更复杂的大型项目,你需要一个测试套件,这也会花费时间。在 CI 中并行运行这两个项目,然后就大功告成了。
不幸的是,在大多数情况下,移除调试符号并不是一个好主意。
你指的“大多数”情况是什么?另外,别忘了,一个在发布时重量为10 MB的二进制文件,如果编译时包含调试符号,重量可能达到300 MB,这显然不适合分发。
关于编译效率,C/C++ 并行编译单独翻译单元的模型似乎比 Rust 模型更先进(但显然排除了整个程序优化的机会)。
Rust 可以并行编译单独的翻译单元,而且确实如此;只是翻译单元(大致上)是一个 crate,而不是一个单独的 C 或 C++ 源文件。
即使对于 crates,Rust 也有增量编译。
Rust 还有与 ninja 相当的工具吗?
这取决于你所说的“与 ninja 相当”是什么意思。
Cargo 是 Rust 项目的标准构建系统,尽管有些用户使用其他系统(有些用户也在 Cargo 之上构建其他系统)。
rust 优先考虑构建时的正确性:没有运行时链接器或动态依赖项。所有检查(类型、特征、所有权)都在执行之前进行。这使得构建对上游更改非常敏感。Docker 使用内容哈希层,因此,即使是很小的上下文编辑也会使缓存失效。如果没有仔细排序层,Rust 就会在每次更改时完全重新编译。
早期设计决策更倾向于运行时而不是编译时 [1]:
> * 借用——Rust 的定义特征。其复杂的指针分析在编译时花费时间来确保运行时安全。
> * 单态化——Rust 将每个泛型实例化转换为自己的机器代码,从而造成代码膨胀,延长编译时间。
> * 堆栈展开——在不可恢复的异常发生后,堆栈展开会向后遍历调用堆栈并运行清理代码。这需要大量的编译时间记账和代码生成。
> * 构建脚本 — 构建脚本允许在编译时运行任意代码,并引入需要编译的自身依赖项。其未知副作用和未知输入输出限制了工具对它们的假设,例如限制了缓存机会。
> * 宏 — 宏需要多次通过来展开,展开后往往生成大量隐藏代码,并限制了部分解析。程序化宏与构建脚本具有类似的负面影响。
> * LLVM 后端 — LLVM 可以生成良好的机器代码,但运行速度相对较慢。过度依赖 LLVM 优化器 — Rust 以生成大量 LLVM IR 并让 LLVM 进行优化而著称。单态化导致的重复加剧了这种情况。
> * 分离编译器/包管理器——虽然语言拥有与编译器分开的包管理器是正常现象,但在 Rust 中,这至少导致 cargo 和 rustc 对整个编译管道的信息都不完整且存在冗余。随着管道中越来越多的部分为了提高效率而被短路,编译器实例之间需要传输更多的元数据,这些元数据大多通过文件系统传输,这会带来额外的开销。
> * 每个编译单元代码生成——rustc 每次编译一个 crate 时都会生成机器代码,但其实没有必要——大多数 Rust 项目都是静态链接的,直到最后的链接步骤才需要机器代码。完全分离分析和代码生成可能会提高效率。
> * 单线程编译器——理想情况下,所有 CPU 在整个编译过程中都处于占用状态。而目前的 Rust 远未达到这个理想状态。由于原始编译器是单线程的,因此该语言对并行编译并不友好。目前正在努力实现编译器的并行化,但它可能永远无法使用所有内核。
> * Trait 一致性 — Rust 的 Traits 具有一种称为“一致性”的属性,这使得定义相互冲突的实现成为不可能。Trait 一致性对代码的存放位置施加了限制。因此,将 Rust 抽象分解为易于并行化的编译单元非常困难。
> * 代码旁边的测试 — Rust 鼓励测试与它们所测试的代码位于同一个代码库中。使用 Rust 的编译模型,这需要对代码进行两次编译和链接,这非常耗时,对于大型 crates 而言尤其如此。
[1]: https://www.pingcap.com/blog/rust-compilation-model-calamity…
一些会导致 Rust 编译速度异常缓慢的代码是复杂的常量表达式。由于编译器可以在编译时评估表达式的子集[1], 因此复杂表达式的评估可能需要无限的时间。如果评估时间过长,长运行 const 评估将默认中止编译。
1 https://doc.rust-lang.org/reference/const_eval.html
编译时间慢是一个特点,可以去泡杯茶。
> 编译时间过长是设计特性
xkcd 总是贴切:https://xkcd.com/303/
另一方面,如果你试图在 C++ 项目中常见的 5-10 分钟编译时间内做些有用的事情,你会变得精神失常。
当我不得不面对这个问题时,我就会打开报纸,在老板面前读一篇文章。
我认为 rustc 并不慢。通常情况下,即使你设置了缓存,rustc 除了访问缓存之外什么也不做,但 cargo/数十个 crates 也会导致编译时间很长。
不是这样的。由于类型系统合理,它只是比许多其他编译器做了更多的工作。
我个人已经不在乎了,因为我使用热补丁:
https://lib.rs/crates/subsecond
Zig 更快,但另一方面,Zig 并不节省内存,所以个人而言我不在乎。这是一种令人印象深刻的语言,我喜欢它的语法和简洁性。但我不再像多年前那样,能够在脑海中保持所有与内存相关的不变量。所以 Zig 不适合我。简单来说,它不是我的目标受众。
为什么 Rust 生态系统不在编译时间进行优化?似乎许多框架和库都鼓励做编译速度较慢的事情。
更准确地说,Rust 的惯用语鼓励做编译速度较慢的事情:到处都是许多小的通用函数。而加快编译速度的最有效方法是通过使用 RTTI 提供一个可复用的通用编译实现,以避免单态化,就像 Swift 在模块边界跨类型使用泛型时所做的那样。但这在运行时效率较低,因为需要进行大量运行时检查和计算来处理不同大小的对象等,许多直接或甚至内联的调用现在变成了虚拟调用等。
以下是一个有些过时但仍然很好的概述,介绍了不同语言(包括 C++、Rust、Swift 和 Zig)中对泛型的各种方法及其取舍:https://thume.ca/2019/07/14/a-tour-of-metaprogramming-models…
它正在开始,但很多人使用 Rust 是因为他们需要(或想要)尽可能好的运行时性能,因此这往往被优先考虑。
生态系统庞大,不同的人有不同的优先级。就是这么简单。
tl;dr:它运行缓慢是因为它在运行时之前发现了比其他任何主流编译语言都多的错误
TL;DR
async
被认为是有害的。对于这个帖子中所有嘲笑 C++ 的人来说,实际上只有一件事会导致 C++ 运行缓慢——非
extern
模板——而 C++ 比 Rust 提供了更多加快运行速度的空间。如今,C++ 也支持异步。
至于模板,除了 extern 模板和在单独的 .cpp 文件中手动管理实例外,我无法想到任何能够像 Rust 一样大幅提高速度的东西。否则,问题基本上是一样的——因为每次都使用不同的类型进行参数化,所以需要反复重新编译相同的代码。
事实上,从默认设置来看,我反而预期 C++ 的表现会更差,因为 C++ 头文件模板在每个包含该头文件的翻译单元中可能具有不同的环境,因此在没有预编译头文件的情况下,编译器基本上不得不假设最坏情况……