FelderaFeldera Logo

Cutting Down Rust Compile Times From 30 to 2 Minutes With One Thousand Crates

使用一千个 crates 将 Rust 编译时间从 30 分钟缩短到 2 分钟

Gerd Zellweger

Gerd Zellweger Head of Engineering / Co-Founder | 2025 年 4 月 15 日

众所周知,Rust 在运行时很快,但在编译时却不尽如人意。对于任何从事过严肃的 Rust 代码库的人来说,这已经不是什么新闻了。有大量的博客文章专门介绍如何节省 cargo build 的时间。

在 Feldera,我们允许用户编写 SQL 来定义表和视图。在底层,我们将 SQL 编译成 Rust 代码,然后使用 rustc 将其编译成一个单一的二进制文件,该文件增量式地维护所有视图,因为新的数据流入表。

过去,我们已经尝试了很多技巧来加速编译:类型擦除、积极的代码去重、限制代码生成行数。这些方法在很大程度上帮助了我们。然而,最近我们开始引入一个新的大型企业客户,他们的 SQL 相当复杂。他们使用 Feldera 编写了许多非常大的程序。例如,其中一个程序有 8562 行 SQL 代码,最终通过 Feldera SQL-to-Rust 编译器转换为约 10 万行 Rust 代码。

需要明确的是,我们编译的不是什么庞大的单体应用。我们说的是约 10 万行生成的 Rust 代码。与 Linux 内核(4000 万行,可以在几分钟内编译完成)相比,这简直是小菜一碟。

然而……这个程序在我的机器上大约需要 25 分钟才能编译完成。更糟糕的是,在我们的客户的设置中,它需要大约 45 分钟。而且这还是在我们已经将生成的代码切换到使用动态分发并几乎消除了所有单态化之后。

这是来自 Feldera 管理器的日志:

[manager] SQL compilation success: pipeline 0196268e-7f98-7de3-b728-0ee339e449fa (program version: 2) (took 101.94s)
[manager] Rust compilation success: pipeline 0196268e-7f98-7de3-b728-0ee339e449fa (program version: 2) (took 1617.77s; source checksum: cbffcb959174; integrity checksum: 709a17251475)

几乎所有时间都花在了编译 Rust 上。SQL-to-Rust 的转换大约需要 1 分 40 秒。更糟糕的是,Rust 构建正在执行相当于 cargo 中的 release 构建,因此每次都是从头开始(除了 cargo crate 依赖项,它们已经在我们给出的时间中被缓存/重用)。即使输入 SQL 中发生微小的更改,也会触发对该大型程序的完全重建。

当然,我们也尝试过 debug 构建。这些构建将时间缩短到大约 5 分钟,但在实践中不可用。我们的客户关心实际的运行时性能:当 SQL 代码类型检查通过时,他们已经知道 Rust 代码将成功编译,并且他们正在运行实时数据管道,并希望看到端到端的延迟和吞吐量。Debug 构建对于这些目的来说太慢且具有误导性。

发生了什么?

这是令人沮丧的部分。

我们使用的是 rustc v1.83,尽管有一台具有 128 个线程的 64 核机器,但 Rust 几乎没有利用它们。在编译期间查看 htop 时,这一点很快变得明显:

An idle machine as seen in htop

没错。一个核心占用 100% 的资源,其余的都在休眠。

我们可以通过将 -Ztime-passes 传递给 RUSTFLAGS 来检测此 crate 的编译(这需要使用 nightly 版本重新编译)。它显示大部分时间都花在 LLVM passes 和代码生成上,不幸的是,它们是单线程的:

time:  0.346; rss:  38MB -> 342MB ( +304MB)	parse_crate
time:  0.000; rss: 344MB -> 345MB (  +1MB)	crate_injection
time:  5.286; rss: 345MB -> 1607MB (+1262MB)	expand_crate
time:  5.287; rss: 345MB -> 1607MB (+1262MB)	macro_expand_crate
time:  0.091; rss: 1607MB -> 1607MB (  +0MB)	AST_validation
time:  0.002; rss: 1607MB -> 1608MB (  +1MB)	finalize_imports
time:  0.029; rss: 1608MB -> 1608MB (  +0MB) finalize_macro_resolutions
time:  2.382; rss: 1608MB -> 1937MB ( +329MB)	late_resolve_crate
time:  0.071; rss: 1937MB -> 1938MB (  +1MB)	resolve_check_unused
time:  0.138; rss: 1938MB -> 1938MB (  +0MB)	resolve_postprocess
time:  2.627; rss: 1607MB -> 1938MB ( +331MB)	resolve_crate
time:  0.069; rss: 1940MB -> 1940MB (  +0MB)	write_dep_info
time:  0.070; rss: 1940MB -> 1940MB (  +0MB)	complete_gated_feature_checking
time:  0.217; rss: 2790MB -> 2651MB ( -139MB)	drop_ast
time:  3.361; rss: 1940MB -> 2353MB ( +414MB)	looking_for_entry_point
time:  3.961; rss: 1940MB -> 2346MB ( +407MB)	misc_checking_1
time:  6.301; rss: 2346MB -> 2007MB ( -339MB)	coherence_checking
time: 44.158; rss: 2346MB -> 3061MB ( +714MB)	type_check_crate
time: 18.773; rss: 3061MB -> 5024MB (+1963MB)	MIR_borrow_checking
time:  4.650; rss: 5024MB -> 5241MB ( +217MB)	MIR_effect_checking
time:  0.360; rss: 5243MB -> 5255MB ( +12MB)	module_lints
time:  0.360; rss: 5243MB -> 5255MB ( +12MB)	lint_checking
time:  0.947; rss: 5255MB -> 5254MB (  -1MB)	privacy_checking_modules
time:  1.587; rss: 5241MB -> 5254MB ( +13MB)	misc_checking_3
time:  0.259; rss: 5254MB -> 5249MB (  -5MB)	monomorphization_collector_root_collections
time: 54.766; rss: 5249MB -> 7998MB (+2749MB)	monomorphization_collector_graph_walk
time:  6.086; rss: 8010MB -> 8565MB ( +554MB)	partition_and_assert_distinct_symbols
time:  0.000; rss: 8414MB -> 8415MB (  +1MB)	write_allocator_module
time: 35.220; rss: 8415MB -> 18037MB (+9622MB)	codegen_to_LLVM_IR
time: 96.733; rss: 5254MB -> 18037MB (+12783MB)	codegen_crate
time: 1333.423; rss: 10070MB -> 3176MB (-6893MB)	LLVM_passes
time: 1303.074; rss: 13594MB -> 756MB (-12837MB)	finish_ongoing_codegen
time:  1.091; rss: 756MB -> 756MB (  +0MB)	run_linker
time:  0.105; rss: 755MB -> 755MB (  +0MB)	link_binary_remove_temps
time:  1.217; rss: 756MB -> 755MB (  -1MB)	link_binary
time:  1.218; rss: 756MB -> 754MB (  -2MB)	link_crate
time:  1.218; rss: 756MB -> 754MB (  -2MB)	link
time: 1483.483; rss:  26MB -> 514MB ( +487MB)	total

有时在这 30 分钟内,Rust 会启动几个线程(也许 3 或 4 个),但它永远不会充分利用机器。差得很远。

A mostly idle machine as seen in htop.

我知道:并行化编译很困难。但这并不是什么边缘情况,观察我们自己,我们清楚地看到了足够多的机会来并行化该程序中的编译。

旁注:您可能想知道增加 Cargo.toml 中的 codegen-units 会怎么样?这难道不会加速这些 passes 吗?根据我们的经验,这无关紧要:对于报告的时间,它设置为默认值 16,但我们也尝试了诸如 256 之类的值,并使用默认的 LTO 配置 (thin local LTO)。这有点令人困惑(作为一名非 rustc 专家)。我很乐意阅读对此的解释。

我们可以做些什么?

我们没有发出包含所有内容的单个大型 crate,而是调整了我们的 SQL-to-Rust 编译器,将输出拆分为许多较小的 crates。每个 crate 仅封装一部分逻辑,整齐地相互依赖,并由一个顶级 main crate 将它们全部引入。

结果非常惊人。这是更改后编译期间的同一 htop 视图:

A very busy machine as seen in htop.

太棒了。现在所有 CPU 始终得到充分利用。这充分说明了: 编译 Rust 程序的时间缩短至 2m10s

[manager] Rust compilation success: pipeline 01962739-79fd-7f03-bbf2-f8e29ce21e1d (program version: 2) (took 150.24s; source checksum: 0336f3eb9dc1; integrity checksum: 6051bcde6674)

我们是如何解决的?

在大多数 Rust 项目中,将逻辑分散在数十个(或数百个)crates 中,充其量是不切实际的,最坏的情况是噩梦。但在我们的例子中,由于 Feldera 在底层的工作方式,它出奇地简单。

当用户在 Feldera 中编写 SQL 时,我们会将其转换为数据流图:节点是转换数据的运算符,边表示数据如何在它们之间流动。这是此类图的一小部分:

A feldera dataflow graph.

由于 Rust 代码完全是从这个结构自动生成的,因此我们可以完全控制如何拆分它。

每个运算符都变成它自己的 crate。每个 crate 都导出一个函数,该函数构建数据流的一个特定部分。它们都遵循相同的可预测形状。顶级的 main crate 只是将它们连接在一起。

pubfncreate_operator_0097dd9de75ffef3(circuit: &RootCircuit,catalog: &mut Catalog,
  i0: &Stream<RootCircuit, IndexedWSet<Tup1<i32>, Tup5<i32, SqlString, F64, F64, Option<i32>>>>,
  i1: &Stream<RootCircuit, IndexedWSet<Tup1<i32>, Tup0>>,
) -> Stream<RootCircuit, WSet<Tup5<i32, SqlString, F64, F64, Option<i32>>>>{
let operator_0097dd9de75ffef3: Stream<RootCircuit, WSet<Tup5<i32, SqlString, F64, F64, Option<i32>>>> = i0.join(&i1, move |p0: &Tup1<i32>, p1: &Tup5<i32, SqlString, F64, F64, Option<i32>>, p2: &Tup0, | ->
  Tup5<i32, SqlString, F64, F64, Option<i32>> {
    Tup5::new(
      (*p1).0,
      (*p1).1.clone(),
      (*p1).2,
      (*p1).3,
      (*p1).4.as_ref().cloned())
  });
return operator_0097dd9de75ffef3;
}

我们仍然需要弄清楚如何命名这些 crates。一种简单但有效的方法是对它们包含的 Rust 代码进行哈希处理,并将其用作 crate 的名称。

这确保了两件事:

a. 我们有唯一的 crate 名称。 b. 更重要的是:对 SQL 的增量更改变得非常有效。

想象一下用户稍微调整了 SQL 代码。发生的情况是,大多数运算符(及其 crates)保持不变(哈希值没有变化),并且 rustc 可以重用以前编译的大部分工件。由于更改而添加的任何新代码都将最终生成一个新的 crate(具有不同的哈希值)。

那么,对于那个庞大的 SQL 程序,我们在讨论多少个 crates 呢?

让我们看一下 feldera 容器内的编译器目录:

ubuntu@12e1de52de1b:~/.feldera/compiler/rust-compilation$ ls crates/
feldera_pipe_operator_000cb1599cb60b91 feldera_pipe_operator_4aab3e223e4ddcf9 feldera_pipe_operator_8d1f38d0358deacf feldera_pipe_operator_d8058d2f87a41ca0
feldera_pipe_operator_004093943841ab45 feldera_pipe_operator_4ae3aa1446d98a19 feldera_pipe_operator_8d30ed71269c765f feldera_pipe_operator_d841ffa208faa462
feldera_pipe_operator_004675554aea30aa feldera_pipe_operator_4aff1d1e8d2a6a9a feldera_pipe_operator_8e25b73d54f6491e feldera_pipe_operator_d88bab492aa0c8f5
feldera_pipe_operator_008ba4153ded3848 feldera_pipe_operator_4b3575ba2e10dad3 feldera_pipe_operator_8e667e68984170e5 feldera_pipe_operator_d8a43a536535a38d
feldera_pipe_operator_00bee114a0d5eb4c feldera_pipe_operator_4b5370144b5268ae feldera_pipe_operator_8eb1e7460e7376f9 feldera_pipe_operator_d8c6422350e6e8fe
feldera_pipe_operator_00d71fa11f791e35 feldera_pipe_operator_4b5d1c560b048f22 feldera_pipe_operator_8edfa111c7ed57b6 feldera_pipe_operator_d968b48784b4f7af
...

然后:

ubuntu@12e1de52de1b:~/.feldera/compiler/rust-compilation$ ls crates/ | wc -l
1106

没错,1,106 个 crates!

听起来过分吗?也许吧。但最终,这就是使 rustc 更加有效的原因。

我们完成了吗?

不幸的是,还没有。这里仍然存在一些谜团。鉴于我们现在几乎在整个编译时间内都充分利用了 128 个线程或 64 个核心,因此我们可以粗略计算一下需要多长时间:25 min / 128 = 12 sec(或者可能是 24 sec,因为超线程不是真正的核心)。然而,编译所有内容需要 170s。当然,我们不能期望在实践中获得线性加速,但仍然比这慢 7 倍 似乎过分了(这些都只是独立运行的并行 rustc 调用)。类似的减速也发生在内存和核心少得多的笔记本电脑级机器上,因此它不仅仅影响非常大的机器。

以下是一些关于可能发生的情况的想法,但我们很乐意听到更多关于此的意见:

结论

通过简单地更改我们在底层生成 Rust 代码的方式,我们使 Feldera 的编译时间可以随着您的硬件进行扩展,而不是与之对抗。过去需要 30-45 分钟的时间,现在在不到 3 分钟的时间内即可编译完成,即使是对于复杂的企业级 SQL 也是如此。

如果您已经在将其 Feldera 推向极限:谢谢您。您的工作负载有助于我们使系统对每个人都更好。